From 9551e043a73f9521f84f7415af40903b031f8c2f Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 27 Oct 2026 12:04:43 -0400 Subject: [PATCH] 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 --- README.md | 2 +- ableton_start.py | 193 ++++++++++++++++++++++++++++++++++++++++++-- config.json | 4 + readiness.py | 91 ++++++++++++++++++--- schema.py | 1 + tests/test_start.py | 58 +++++++++++++ 6 files changed, 331 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ba50c26..c57c2b6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ableton_start.py b/ableton_start.py index 3d559f5..13be7dc 100644 --- a/ableton_start.py +++ b/ableton_start.py @@ -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) diff --git a/config.json b/config.json index d8ab6fb..6e5016a 100644 --- a/config.json +++ b/config.json @@ -1265,6 +1265,10 @@ "vocal_positions": [ "Vocalist", "[Leader]" + ], + "transpose_locations": [ + "SC", + "WS" ] }, "ready_page": { diff --git a/readiness.py b/readiness.py index 13ec801..3ffab38 100644 --- a/readiness.py +++ b/readiness.py @@ -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") diff --git a/schema.py b/schema.py index 7daf1c3..dd3d850 100644 --- a/schema.py +++ b/schema.py @@ -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") diff --git a/tests/test_start.py b/tests/test_start.py index 05f069b..621e86c 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -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()