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:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -1265,6 +1265,10 @@
|
|||||||
"vocal_positions": [
|
"vocal_positions": [
|
||||||
"Vocalist",
|
"Vocalist",
|
||||||
"[Leader]"
|
"[Leader]"
|
||||||
|
],
|
||||||
|
"transpose_locations": [
|
||||||
|
"SC",
|
||||||
|
"WS"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ready_page": {
|
"ready_page": {
|
||||||
|
|||||||
+79
-12
@@ -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"
|
||||||
r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60)
|
if not rc.get("transpose_locations"):
|
||||||
if not r.get("source"):
|
r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60)
|
||||||
return "NO SET TO COPY"
|
if not r.get("source"):
|
||||||
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
|
return "NO SET TO COPY"
|
||||||
return True
|
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":
|
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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user