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 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 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 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 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. 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") STATUS = os.path.expanduser("~/Library/Caches/status-panel-start.json")
LOCK = os.path.expanduser("~/Library/Caches/status-panel-start.lock") 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} 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) FINISHED = re.compile(r"^(.*?)\s*\[\s*([A-G](?:#|b)?m?)\s*\]\s*(?:-\s*CUSTOM)?\s*$", re.I)
NUMBERED = re.compile(r"^\s*(\d+)\s*-") NUMBERED = re.compile(r"^\s*(\d+)\s*-")
SALVATION = re.compile(r"salvation", re.I) 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 # 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). # 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" 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: class Status:
"""The job's progress, written where the READY look reads it.""" """The job's progress, written where the READY look reads it."""
def __init__(self, data): def __init__(self, data, path=None):
self.data = data self.data, self.path = data, path or STATUS
self.write() self.write()
def write(self, **kw): def write(self, **kw):
self.data.update(kw, time=time.time()) self.data.update(kw, time=time.time())
tmp = STATUS + ".tmp" tmp = self.path + ".tmp"
with open(tmp, "w") as f: with open(tmp, "w") as f:
json.dump(self.data, f) json.dump(self.data, f)
os.replace(tmp, STATUS) os.replace(tmp, self.path)
# --- reading and writing sets ---------------------------------------------------------------------- # --- reading and writing sets ----------------------------------------------------------------------
@@ -1528,6 +1530,186 @@ def consolidate(path, cfg, status, log, live=None):
live.close() 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 ---------------------------------------------------------------------------------- # --- the job ----------------------------------------------------------------------------------
def projects_dir(cfg): def projects_dir(cfg):
@@ -1654,7 +1836,8 @@ def main(P):
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) > 1: # the detached job if len(sys.argv) > 1: # the detached job
try: 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: finally:
try: try:
os.remove(LOCK) os.remove(LOCK)
+4
View File
@@ -1265,6 +1265,10 @@
"vocal_positions": [ "vocal_positions": [
"Vocalist", "Vocalist",
"[Leader]" "[Leader]"
],
"transpose_locations": [
"SC",
"WS"
] ]
}, },
"ready_page": { "ready_page": {
+74 -7
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 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"]: for loc in P["locations"]:
d = folder(loc) 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)) 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"), "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"), "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"]), "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): def ableton_look(cfg, rc, plan):
@@ -1257,13 +1271,38 @@ def eval_tune(rc, plan, look, sent=None):
return line 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() 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()): for loc, b in sorted((look.get("bundles") or {}).items()):
if b.get("copying"): if b.get("copying"):
line.fault(WARN, "COPYING TO " + loc, "the copy to %s is still running" % loc) line.fault(WARN, "COPYING TO " + loc, "the copy to %s is still running" % loc)
elif not b.get("als"): elif not b.get("als"):
line.fault(ERR, "NOT SENT " + loc, "no set in %s" % b.get("folder")) 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" line.info = " ".join(sorted(look.get("bundles") or {})) or "NO LOCATIONS"
return line return line
@@ -1835,6 +1874,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
continue continue
try: try:
cplan = src["pco_campus"](campus["service_type"]) 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) res = campus_ltc(rc, plan, na_songs, campus, cplan)
eval_campus_ltc(rc, res, lines["timecode"]) 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"]])) 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)) lines["timecode"].fault(WARN, "%s %s" % (tag, msg), "%s: %s" % (campus.get("name") or tag, e))
LAST["ltc_other"] = others LAST["ltc_other"] = others
if rc.get("locations"): 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 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. # 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(): def start_in_live():
"""START is consolidating in Live just now (it holds AbletonOSC's answers on the Tracks Mac).""" """START or TRACKS OUT is consolidating in Live just now (it holds AbletonOSC's answers on the Tracks Mac)."""
st = LAST.get("start_status") or {} return any(st.get("state") == "running" and time.time() - (st.get("live") or 0) < 600
return 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): 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") plan = LAST.get("plan")
if not plan: if not plan:
return "NO PLAN YET" return "NO PLAN YET"
if not rc.get("transpose_locations"):
r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60) r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60)
if not r.get("source"): if not r.get("source"):
return "NO SET TO COPY" return "NO SET TO COPY"
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)") log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
return True 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": if action == "mount_share":
# The computer's keep-mounted agent (remote-access/mac-keep-mounted.sh) mounts it again, now. # 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") 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", "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"]) 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.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 " 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, " "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") "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(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"))) 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): def test_a_set_that_is_there_is_left_alone(self):
os.makedirs(os.path.join(self.proj, "NA", "261101 NA Project")) os.makedirs(os.path.join(self.proj, "NA", "261101 NA Project"))
st = self.run_job() st = self.run_job()