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
+1 -1
View File
@@ -132,7 +132,7 @@ HyperDeck is recording). **STATUS PAGE** (bottom left) goes back.
| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE |
| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens in 2.5 ms steps out to almost a 16th either way, finds the strongest hit and where it starts (halfway up its rise); what is under the cue is looked at from 10 ms before it to 25 ms after (the stems' hits start a little after the grid). Each song's cues that sit on solid hits set what "on time" is for that song (its stem's hits start 7-12 ms after the grid). **14 CUES OFF** (amber): a strong hit more than 25 ms from the cue while the cue sits on a quiet spot; a cue already on a solid hit (a double hit follows) is never named. The details say `JEHOVAH bar 405.3.2: Blinder Flash 2 is 145 ms after the hit (about a 16th)` (bars as Live shows them, 4/4). Listened to once per saved set. | moves every cue that is off onto its hit in the open set (all flash/FX notes on that 16th; onto the 16th grid when the hit is within 10 ms of it), keeping where each cue ends, then saves the set (File › Save Live Set in Live, clicked through System Events: `/usr/bin/osascript` has Accessibility on the Tracks Mac since 2026-10-22; `ready.light_autosave`), but only when the set open in Live is this weekend's. If it cannot save, the key says **SAVE SET (n MOVED)** until the set is saved; Cmd+Z in Live takes a move back. Never runs by itself. Uses the status panel's command in AbletonOSC (`tools/abletonosc`: `statuspanel.py` plus a hook at the end of AbletonOSC's `abletonosc/__init__.py`; an AbletonOSC update removes it, so run `tools/abletonosc/install.sh` again, then restart Companion's `ableton` connection) |
| row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | |
| row 3 (outgoing) | TRACKS OUT | PROP OUT | START | | | | |
| row 3 (outgoing) | TRACKS OUT | every location in `"locations"` (AB, SC, WS) has this weekend's project folder; the ones in `"transpose_locations"` (SC, WS) in the keys of their own Planning Center plan: **WS THE LORD IS H. B NOT D** (red) is a locator still in NA's key, **CONSOLIDATE WS** (amber) transposed clips not yet rendered | one job on the Tracks Mac (Waters_Worship_Location_Setup.pdf): NA's finished set is copied to each location that has none (as `261101 WS Project`, without `Backup/`, under a temporary name until complete; AB keeps NA's keys). For SC and WS every song whose key their plan changes gets its pitched stems transposed (warp on, Complex Pro, formants 0; drums and other unpitched stems stay), its locator, Waves Tune key cue and pads changed, the Call to Worship following the first song and Salvation the song before it; the old .als is kept in `Backup/`. Then each set is consolidated in Live (Freeze + Flatten, warp off, as START does) and Live goes back to the set it had open. Refused while NA's set has clips still to consolidate; a location set that is open in Live is left alone (named in the details). Never runs by itself. |
The READY key at row 3 col 7 (and the one on the status page) shows how many keys still have something to do and
the first of them.
+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)
+4
View File
@@ -1265,6 +1265,10 @@
"vocal_positions": [
"Vocalist",
"[Leader]"
],
"transpose_locations": [
"SC",
"WS"
]
},
"ready_page": {
+79 -12
View File
@@ -553,7 +553,21 @@ for f in P["finished"]:
out["finished"][f] = sorted(n for n in os.listdir(f) if "[" in n) if os.path.isdir(f) else None
for loc in P["locations"]:
d = folder(loc)
out["bundles"][loc] = {"folder": d, "als": bool(newest_als(d)), "copying": os.path.isdir(d + ".copying")}
a = newest_als(d)
b = {"folder": d, "als": bool(a), "copying": os.path.isdir(d + ".copying")}
if a and loc in P.get("transpose_locs", []):
try:
r = ET.parse(gzip.open(a)).getroot()
b["locators"] = [l.find("Name").get("Value") for l in r.findall("LiveSet/Locators/Locators/Locator")]
b["transposed"] = sum(1 for c in r.iter("AudioClip") if c.find("PitchCoarse") is not None
and float(c.find("PitchCoarse").get("Value")) != 0)
except Exception:
pass
out["bundles"][loc] = b
try:
out["loc_job"] = json.load(open(os.path.expanduser("~/Library/Caches/status-panel-locations.json")))
except Exception:
out["loc_job"] = {}
print(json.dumps(out))
'''
@@ -565,7 +579,7 @@ def ableton_params(rc, plan):
"finished": rc.get("finished_folders", []), "tune_track": rc.get("tune_track", "WAVES TUNE"),
"tune_library": rc.get("tune_library", ""), "lighting_track": rc.get("lighting_track", "LIGHTING CUES"),
"hit_notes": rc.get("light_hit_notes", list(range(30, 40))), "hit_stems": rc.get("hit_stems", ["DRUMS"]),
"light_library": rc.get("light_library", "")}
"light_library": rc.get("light_library", ""), "transpose_locs": rc.get("transpose_locations", [])}
def ableton_look(cfg, rc, plan):
@@ -1257,13 +1271,38 @@ def eval_tune(rc, plan, look, sent=None):
return line
def eval_track_bundle(rc, look):
LOCATOR = re.compile(r"^\s*(\d+)\s*-\s*(.*?)\s*-\s*([A-G](?:#|b)?m?)\s*$")
def eval_track_bundle(rc, look, keys=None, now=None):
"""Every location has this weekend's set; the ones in `transpose_locations` in the keys their own plan has
(`keys`: {"WS": [{"title", "key"}]}) and consolidated."""
line = Line()
job = look.get("loc_job") or {}
now = now or time.time()
running = job.get("state") == "running" and now - (job.get("time") or 0) < 1800
if running:
line.fault(WARN, (job.get("step") or "WORKING")[:24], "TRACKS OUT is making the locations' sets: %s" % job.get("step"))
elif job.get("state") == "failed" and now - (job.get("time") or 0) < 7 * 86400:
line.fault(ERR, "FAILED " + (job.get("step") or "")[:16], "the last TRACKS OUT job stopped: %s" % job.get("step"))
if not running and now - (job.get("time") or 0) < 86400:
line.notes.extend(job.get("log") or [])
for loc, b in sorted((look.get("bundles") or {}).items()):
if b.get("copying"):
line.fault(WARN, "COPYING TO " + loc, "the copy to %s is still running" % loc)
elif not b.get("als"):
line.fault(ERR, "NOT SENT " + loc, "no set in %s" % b.get("folder"))
elif "locators" in b and not running:
for name in b["locators"]:
m = LOCATOR.match(name or "")
want = next((x["key"] for x in (keys or {}).get(loc) or [] if m and x.get("key") and same(x["title"], m.group(2))), None)
if want and not same_key(m.group(3), want, mode=False):
line.fault(ERR, "%s %s %s NOT %s" % (loc, short(m.group(2), 10), m.group(3), want),
"%s's plan has %s in %s; its set's locator \"%s\" says %s: hold TRACKS OUT" % (
loc, m.group(2), want, name, m.group(3)))
if b.get("transposed"):
line.fault(WARN, "CONSOLIDATE " + loc, "%s's set has %d transposed clips not consolidated: hold TRACKS OUT" % (
loc, b["transposed"]))
line.info = " ".join(sorted(look.get("bundles") or {})) or "NO LOCATIONS"
return line
@@ -1835,6 +1874,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
continue
try:
cplan = src["pco_campus"](campus["service_type"])
LAST.setdefault("campus_keys", {})[campus.get("short")] = [
{"title": it["title"], "key": it["key"]} for it in plan_songs(rc, cplan)]
res = campus_ltc(rc, plan, na_songs, campus, cplan)
eval_campus_ltc(rc, res, lines["timecode"])
others.append(dict(res, songs=[dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in res["songs"]]))
@@ -1844,7 +1885,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
lines["timecode"].fault(WARN, "%s %s" % (tag, msg), "%s: %s" % (campus.get("name") or tag, e))
LAST["ltc_other"] = others
if rc.get("locations"):
lines["track_bundle"] = eval_track_bundle(rc, al)
lines["track_bundle"] = eval_track_bundle(rc, al, LAST.get("campus_keys"), now)
loc_job_handback(cfg, rc, al.get("loc_job") or {})
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.
@@ -2161,9 +2203,17 @@ def start_look(cfg, rc, services):
def start_in_live():
"""START is consolidating in Live just now (it holds AbletonOSC's answers on the Tracks Mac)."""
st = LAST.get("start_status") or {}
return st.get("state") == "running" and time.time() - (st.get("live") or 0) < 600
"""START or TRACKS OUT is consolidating in Live just now (it holds AbletonOSC's answers on the Tracks Mac)."""
return any(st.get("state") == "running" and time.time() - (st.get("live") or 0) < 600
for st in (LAST.get("start_status") or {}, LAST.get("loc_job") or {}))
def loc_job_handback(cfg, rc, st):
"""TRACKS OUT's job used AbletonOSC from the Tracks Mac: give it back to Companion once it is done."""
LAST["loc_job"] = st
if st.get("live") and st.get("state") != "running" and LAST.get("loc_handed") != st["live"]:
hand_back(cfg, rc["tracks_host"])
LAST["loc_handed"] = st["live"]
def eval_start(rc, services, look, now):
@@ -2464,11 +2514,28 @@ def run_action(cfg, action, chk=None, sleep=time.sleep):
plan = LAST.get("plan")
if not plan:
return "NO PLAN YET"
r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60)
if not r.get("source"):
return "NO SET TO COPY"
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
return True
if not rc.get("transpose_locations"):
r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60)
if not r.get("source"):
return "NO SET TO COPY"
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
return True
# WS and SC in their own plans' keys (read now), AB as NA's: one job on the Tracks Mac, consolidating in Live.
keys = {}
for c in rc.get("ltc_campuses") or []:
if c.get("short") in rc["transpose_locations"] and c.get("service_type"):
cplan = pco_plan(rc, time.time(), c["service_type"], team=False)
keys[c["short"]] = [{"title": it["title"], "key": it["key"]} for it in plan_songs(rc, cplan)]
missing = [x for x in rc["transpose_locations"] if x not in keys]
if missing:
return "NO PLAN FOR " + " ".join(missing)
params = dict(start_params(rc, [], "run"), **ableton_params(rc, plan))
params.update(job="locations", site=rc.get("site_folder", ""), transpose=keys)
r = run_remote(cfg, rc["tracks_host"], start_script(params), timeout=60)
if r.get("busy"):
return "START IS BUSY"
log.info("FIX TRACKS OUT: the locations' sets (%s in their own keys)", ", ".join(sorted(keys)))
return True if r.get("started") else "DID NOT START"
if action == "mount_share":
# The computer's keep-mounted agent (remote-access/mac-keep-mounted.sh) mounts it again, now.
label = "org.waterschurch.keep-mounted." + rc.get("prop_bundle_share_agent", "location-assets")
+1
View File
@@ -441,6 +441,7 @@ field("ready.finished_folders", "list", [], "Folders of finished songs named \"S
"them: each song must have a version in the key Planning Center gives it.", "Ready",
example=["~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/Finished Songs"])
field("ready.locations", "list", [], "The locations that get this location's tracks.", "Ready")
field("ready.transpose_locations", "list", [], "Of those, the ones whose set TRACKS OUT puts in the keys of their own Planning Center plan (the rest get this location's keys).", "Ready")
field("ready.resi_vox", "dict", None, "RESI VOX: which mic the Resi song sends to the locations. {\"aja\": the Resi AJA's Dante "
"name, \"channel\": its Lead Vocal channel (21), \"mics\": the wireless receiver (BOH-AD4Q-A), \"buttons\": {VOX: [page, row, "
"col]} of Companion's VOX AJA buttons}. The lead comes from PCO's song description, their VOX from the lineup screen.", "Ready")
+58
View File
@@ -343,6 +343,64 @@ class TestBuild(unittest.TestCase):
self.assertTrue(os.path.isdir(os.path.join(os.path.dirname(path), "Ableton Project Info"))) # else Live finds no audio
self.assertTrue(os.path.isdir(os.path.join(self.fin, "OLD SONG [Eb]", "Ableton Project Info")))
def test_the_locations_sets_in_their_own_keys(self):
"""Waters_Worship_Location_Setup.pdf: WS (and SC) from the finished NA set in their own plan's keys; AB as NA."""
self.run_job()
na = os.path.join(self.proj, "NA", "261101 NA Project", "261101 NA Project.als")
root = A.load(na)
for c in root.iter("AudioClip"): # NA consolidated
A.setv(c, "PitchCoarse", 0)
A.save(root, na)
A.LOC_STATUS = os.path.join(self.dir, "loc.json")
job = dict(self.cfg, job="locations", site="NA", date="261101", folder="{date} {loc} Project", locations=["AB", "WS"],
transpose={"WS": [{"title": "Grace", "key": "B"}, {"title": "Something Else", "key": "C"}]})
path = os.path.join(self.proj, "WS", "261101 WS Project", "261101 WS Project.als")
orig = A.live_set
A.live_set = lambda: path # WS's set open in Live: not touched
try:
A.run_locations(job)
finally:
A.live_set = orig
with open(A.LOC_STATUS) as f:
self.assertIn("WS: ITS SET IS OPEN IN LIVE: OPEN ANOTHER SET, THEN HOLD TRACKS OUT", json.load(f)["log"])
self.assertIn("2- GRACE - E", [A.val(l, "Name") for l in A.load(path).iter("Locator")])
A.run_locations(job)
st = json.load(open(A.LOC_STATUS))
self.assertEqual(st["state"], "done", st.get("trace"))
self.assertTrue(os.path.exists(os.path.join(self.proj, "AB", "261101 AB Project", "261101 AB Project.als")))
ab = A.load(os.path.join(self.proj, "AB", "261101 AB Project", "261101 AB Project.als")).find("LiveSet")
self.assertIn("2- GRACE - E", [A.val(l, "Name") for l in ab.findall("Locators/Locators/Locator")]) # AB keeps NA's keys
root = A.load(path)
ls = root.find("LiveSet")
self.assertEqual(A.check(root), [])
names = [A.val(l, "Name") for l in sorted(ls.findall("Locators/Locators/Locator"), key=lambda l: A.fval(l, "Time"))
if not A.val(l, "Name").startswith("LOOP")]
self.assertEqual(names, ["1- CALL TO WORSHIP - B", "2- GRACE - B", "3- GRACE - B", "4- BRAND NEW - D", "5- SALVATION - B"])
by = {A.tname(t): t for t in A.tracks(ls)}
self.assertEqual([(A.val(c, "Name"), A.val(c, "ProgramChange")) for c in A.clips(by["WAVES TUNE"])],
[("Bmaj", "11"), ("Bmaj", "11"), ("Bmaj", "11"), ("Bmaj", "11")])
self.assertEqual([A.val(c, "Name")[:2] for c in A.clips(by["AMBIENT PADS"])], ["B ", "B ", "B ", "B "])
self.assertTrue(os.path.exists(os.path.join(os.path.dirname(path), "Samples/Imported/B - WARM - CHURCHFRONT PADS.wav")))
g1 = [t for t in A.tracks(ls) if A.tname(t) == "GRACE"][0]
keys = [t for t in A.family(ls, g1) if A.tname(t).startswith("5-Keys")][0]
drums = [t for t in A.family(ls, g1) if A.tname(t) == "6-Drums (Live)"][0]
self.assertEqual((A.val(A.clips(keys)[0], "IsWarped"), A.val(A.clips(keys)[0], "WarpMode"), A.val(A.clips(keys)[0], "PitchCoarse")),
("true", "6", "-5"))
self.assertEqual(A.val(A.clips(drums)[0], "PitchCoarse"), "0") # unpitched: as it was
self.assertEqual([x[1] for x in A.pending(root)], ["5-Keys 1 [x]"]) # consolidated next, in Live
self.assertIn("WS: GRACE E>B (1 CLIPS)", st["log"])
self.assertIn("WS: NO BRAND NEW IN ITS PLAN, LEFT IN D", st["log"])
self.assertIn("WS: SOMETHING ELSE IS NOT IN THE SET", st["log"])
self.assertTrue([n for n in os.listdir(os.path.join(os.path.dirname(path), "Backup")) if "before WS keys" in n])
# Again: nothing left to change.
A.run_locations(job)
st = json.load(open(A.LOC_STATUS))
self.assertEqual([r.get("keys_changed") for r in st["results"]], [0])
# TRACKS OUT on the READY page: the locator against WS's plan, and the clips still to consolidate.
line = rd.eval_track_bundle({}, {"bundles": {"WS": {"als": True, "locators": names, "transposed": 1}}},
{"WS": [{"title": "Grace", "key": "B"}, {"title": "Brand New", "key": "C"}]})
self.assertEqual([m for _, m, _ in line.faults], ["WS BRAND NEW D NOT C", "CONSOLIDATE WS"])
def test_a_set_that_is_there_is_left_alone(self):
os.makedirs(os.path.join(self.proj, "NA", "261101 NA Project"))
st = self.run_job()