TRACKS OUT builds the locations' sets (Location Setup PDF): SC and WS in their own PCO keys (pitched stems transposed, locator, Waves Tune, pads, CtW/Salvation), consolidated in Live; AB as NA; key check per location; never edits a set open in Live

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 12:04:43 -04:00
co-authored by Claude Opus 5.5
parent e9c2ef0b6c
commit 9551e043a7
6 changed files with 331 additions and 18 deletions
+188 -5
View File
@@ -49,10 +49,12 @@ import xml.etree.ElementTree as ET
STATUS = os.path.expanduser("~/Library/Caches/status-panel-start.json")
LOCK = os.path.expanduser("~/Library/Caches/status-panel-start.lock")
LOC_STATUS = os.path.expanduser("~/Library/Caches/status-panel-locations.json") # TRACKS OUT's job
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)
LOCATOR = re.compile(r"^\s*(\d+)\s*-\s*(.*?)\s*-\s*([A-G](?:#|b)?m?)\s*$") # "3- THE LORD IS HERE - B"
# 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"
@@ -146,16 +148,16 @@ def template_for(routing, name):
class Status:
"""The job's progress, written where the READY look reads it."""
def __init__(self, data):
self.data = data
def __init__(self, data, path=None):
self.data, self.path = data, path or STATUS
self.write()
def write(self, **kw):
self.data.update(kw, time=time.time())
tmp = STATUS + ".tmp"
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(self.data, f)
os.replace(tmp, STATUS)
os.replace(tmp, self.path)
# --- reading and writing sets ----------------------------------------------------------------------
@@ -1528,6 +1530,186 @@ def consolidate(path, cfg, status, log, live=None):
live.close()
# --- the locations' sets (TRACKS OUT) ----------------------------------------------------------------
# Waters_Worship_Location_Setup.pdf: each location's set is made from the finished NA set. AB keeps NA's keys; WS
# and SC are put in the keys their own Planning Center plan has: the pitched stems transposed the workflow's way
# (then consolidated in Live), the locator, the Waves Tune key cue and the pads changed with them, the Call to
# Worship and Salvation following the song they go with.
def location_keys(ls, songs):
"""[time, locator, number, title, key in the set, key the location wants (None: left as it is)] for every
numbered locator, in order. `songs`: the location plan's songs ({"title", "key"})."""
rows = []
for l in sorted(ls.findall("Locators/Locators/Locator"), key=lambda l: fval(l, "Time")):
m = LOCATOR.match(val(l, "Name") or "")
if not m:
continue
title = m.group(2).strip()
want = next((x["key"] for x in songs if key_of(x.get("key")) and (norm(x["title"]) == norm(title) or close_titles(x["title"], title))), None)
rows.append([fval(l, "Time"), l, int(m.group(1)), title, m.group(3), want])
for i, r in enumerate(rows):
if r[5]:
continue
if norm(r[3]).startswith("CALL TO WORSHIP"): # the first song's key
nxt = next((x for x in rows[i + 1:] if x[5]), None)
if nxt and same_key(r[4], nxt[4]):
r[5] = nxt[5]
elif SALVATION.search(r[3]): # the key of the song before it, as in NA
prev = next((x for x in reversed(rows[:i]) if x[5] and same_key(x[4], r[4])), None)
if prev:
r[5] = prev[5]
return rows
def localize(path, loc, songs, cfg, log):
"""One location's set (a copy of NA's) put in the location's keys, in place (the old .als kept in Backup/).
Returns how many locators changed key."""
root = load(path)
ls = root.find("LiveSet")
proj = os.path.dirname(path)
rows = location_keys(ls, songs)
for x in songs:
if not any(norm(x["title"]) == norm(r[3]) or close_titles(x["title"], r[3]) for r in rows):
log.append("%s: %s IS NOT IN THE SET" % (loc, x["title"].upper()))
for r in rows:
if not r[5] and not norm(r[3]).startswith("CALL TO WORSHIP") and not SALVATION.search(r[3]):
log.append("%s: NO %s IN ITS PLAN, LEFT IN %s" % (loc, r[3].upper(), r[4]))
todo = [(i, r) for i, r in enumerate(rows) if r[5] and semitones(r[4], r[5])]
if not todo:
return 0
by_name = {norm(tname(t)): t for t in tracks(ls)}
pads_t, tune_t = by_name.get(norm(cfg["pad_track"])), by_name.get(norm(cfg["tune_track"]))
unpitched = cfg.get("unpitched") or UNPITCHED
files = Files(proj)
groups = [(song_span(family(ls, g))[0], family(ls, g)) for g in song_groups(ls, cfg["keep_groups"])]
for i, r in todo:
t0, t1 = r[0] - 0.01, (rows[i + 1][0] - 0.01 if i + 1 < len(rows) else 1e12)
semis = semitones(r[4], r[5])
setv(r[1], "Name", "%d- %s - %s" % (r[2], r[3], r[5]))
tune = tune_for(cfg["_tunes"], r[5])
for c in clips(tune_t) if tune_t is not None else []:
if t0 <= span(c)[0] < t1:
if tune:
setv(c, "Name", tune[0])
setv(c, "ProgramChange", tune[1])
else:
log.append("%s: NO WAVES TUNE CUE FOR %s" % (loc, r[5]))
pad = cfg["_pads"].get((key_of(r[5]) or (None,))[0])
ev = events(pads_t) if pads_t is not None else None
for c in list(ev) if ev is not None else []:
a, b = span(c)
if t0 <= a < t1:
if pad:
new = pad_clip(c, pad, a, b, tempo_at(ls, a))
files.adopt(new, None, "")
ev.insert(list(ev).index(c), new)
ev.remove(c)
else:
log.append("%s: NO PAD IN %s" % (loc, r[5]))
n = 0
for start, fam in groups:
if start is not None and t0 <= start < t1:
for t in fam:
if t.tag == "AudioTrack" and pitched(tname(t), unpitched):
for c in clips(t):
transpose(c, int(fval(c, "PitchCoarse")) + semis, tempo_at(ls, span(c)[0]))
n += 1
log.append("%s: %s %s>%s%s" % (loc, r[3].upper(), r[4], r[5], " (%d CLIPS)" % n if n else ""))
problems = check(root)
if problems:
raise RuntimeError("%s SET CHECK: %s" % (loc, problems[0]))
os.makedirs(os.path.join(proj, "Backup"), exist_ok=True)
shutil.copyfile(path, os.path.join(proj, "Backup", "%s [before %s keys %s].als" % (
os.path.splitext(os.path.basename(path))[0], loc, time.strftime("%Y-%m-%d %H%M%S"))))
files.copy(proj)
save(root, path)
return len(todo)
def copy_project(src, dst):
"""NA's project to a location's folder, the way TRACKS OUT always has: under a temporary name, without
Backup/, the set renamed for the location, then put in place."""
tmp = dst + ".copying"
os.makedirs(tmp, exist_ok=True)
subprocess.run(["/usr/bin/rsync", "-a", "--exclude", "Backup/", src + "/", tmp + "/"], check=True, capture_output=True)
old = os.path.join(tmp, os.path.basename(src) + ".als")
if os.path.exists(old):
os.rename(old, os.path.join(tmp, os.path.basename(dst) + ".als"))
if os.path.exists(dst):
raise RuntimeError("%s APPEARED WHILE COPYING" % os.path.basename(dst))
os.rename(tmp, dst)
def run_locations(cfg):
"""TRACKS OUT: every location gets this weekend's set; WS and SC in their own keys, consolidated in Live."""
status = Status({"state": "running", "started": time.time(), "step": "STARTING", "results": [], "log": []}, LOC_STATUS)
log = status.data["log"]
try:
cfg["_pads"] = pads_index(cfg.get("pads_folder"))
cfg["_tunes"] = tune_index(cfg.get("tune_library"))
base = os.path.expanduser(cfg["projects"])
def folder(loc):
return os.path.join(base, loc, cfg["folder"].format(date=cfg["date"], loc=loc))
src = folder(cfg["site"])
src_als = os.path.join(src, os.path.basename(src) + ".als")
if not os.path.exists(src_als):
raise RuntimeError("NO %s SET" % cfg["site"])
if pending(load(src_als), cfg.get("unpitched") or UNPITCHED):
raise RuntimeError("%s IS NOT CONSOLIDATED YET" % cfg["site"])
work = []
for loc in cfg["locations"]:
dst = folder(loc)
if not os.path.exists(dst) and not os.path.isdir(dst + ".copying"):
status.write(step="COPYING TO %s" % loc)
copy_project(src, dst)
log.append("COPIED TO %s" % loc)
songs = (cfg.get("transpose") or {}).get(loc)
if songs is None:
continue
path = os.path.join(dst, os.path.basename(dst) + ".als")
if not os.path.exists(path):
log.append("%s: NO %s" % (loc, os.path.basename(path)))
continue
if live_set() == path:
# Live holds its own copy: a change on disk would be lost on its next save (or overwrite work in it).
log.append("%s: ITS SET IS OPEN IN LIVE: OPEN ANOTHER SET, THEN HOLD TRACKS OUT" % loc)
continue
status.write(step="%s KEYS" % loc)
changed = localize(path, loc, songs, cfg, log)
status.data["results"].append({"loc": loc, "keys_changed": changed})
work.append(path)
live, before = None, live_set()
try:
for path in work if cfg.get("consolidate", True) else []:
if not pending(load(path), cfg.get("unpitched") or UNPITCHED):
continue
try:
live = live or Live()
res = consolidate(path, cfg, status, log, live)
except RuntimeError as e:
log.append("%s: CONSOLIDATE IN LIVE: %s" % (os.path.basename(path)[:-4].upper(), e))
continue
status.data["results"].append(res)
status.write()
# Live goes back to the set it had open (NA's).
if live and before and os.path.exists(before) and live_set() != before:
status.write(step="REOPENING %s" % os.path.basename(before)[:-4].upper()[:14])
subprocess.run(["/usr/bin/open", "-a", live_app(), before], capture_output=True, timeout=30)
end = time.time() + 180
while time.time() < end and live_set() != before:
time.sleep(2)
if live_set() != before:
log.append("OPEN %s IN LIVE AGAIN" % os.path.basename(before)[:-4].upper())
finally:
if live:
live.close()
status.write(state="done", step="DONE")
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:])
# --- the job ----------------------------------------------------------------------------------
def projects_dir(cfg):
@@ -1654,7 +1836,8 @@ def main(P):
if __name__ == "__main__":
if len(sys.argv) > 1: # the detached job
try:
run(json.load(open(sys.argv[1])))
job = json.load(open(sys.argv[1]))
(run_locations if job.get("job") == "locations" else run)(job)
finally:
try:
os.remove(LOCK)