READY page: song keys, Waves Tune, live routing and in-ear sends
- SONG KEYS: each song's PCO key against the set's locator, the pad under it and Finished Songs - W TUNE: the Waves Tune clip starting each song is the PCO key (and sends that key's program) - ROUTING / IN EARS: read live over AbletonOSC (then handed back to Companion); the hold, and auto-fix in its hours, put stems back on the routing template (from 261025 NA Project) and raise in-ear sends at -inf to 0 dB Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+430
-7
@@ -9,6 +9,13 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p
|
||||
mix_foh every song is mixed (a song whose tracks are all at 0 dB is not) and its
|
||||
stems go out to the console (Ext. Out), not to Master
|
||||
lights the lighting cue track has a clip under every song
|
||||
tune the Waves Tune clip that starts each song (a program change from the "Waves Tune - midi
|
||||
Program Change" clips) is the song's key in PCO
|
||||
routing every stem goes where this location's template says (Keys -> Ext. Out 5/6 Keys), read live
|
||||
from Ableton over AbletonOSC, so tracks just dragged in count before the set is saved
|
||||
ears no song stem's in-ear send (send A) is at -inf: dragged-in songs come in that way
|
||||
keys every song is in the key PCO gives it: the set's locator for it ("3- JEHOVAH - B"), the pad
|
||||
clip under it ("B - WARM - CHURCHFRONT PADS") and a Finished Songs folder ("JEHOVAH [B]")
|
||||
prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions
|
||||
match the slides (MC announcements: one slide each; baptisms: every name)
|
||||
timecode every song's presentation has a timeline, and PCO's LTC note for it is the timecode
|
||||
@@ -38,6 +45,8 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -231,7 +240,7 @@ def pco_plan(rc, now, service_type=None, team=True):
|
||||
note_ids[n.get("category_name") or ""] = ref["id"]
|
||||
out_items.append({"id": it.get("id"), "note_ids": note_ids, "title": a.get("title") or "", "type": a.get("item_type") or "",
|
||||
"position": a.get("service_position") or "", "description": a.get("description") or "",
|
||||
"notes": ns})
|
||||
"notes": ns, "key": a.get("key_name") or ""})
|
||||
members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid)) if team else ([], [])
|
||||
teams = {i["id"]: (i.get("attributes") or {}).get("name") for i in inc if i.get("type") == "Team"}
|
||||
team = []
|
||||
@@ -445,14 +454,31 @@ def summary(path):
|
||||
ltc = {"start": float(g("CurrentStart")), "name": g("Name"), "warped": g("IsWarped") == "true",
|
||||
"offset": float(g("Loop/LoopStart") or 0)}
|
||||
break
|
||||
pads = []
|
||||
for t in list(ls.find("Tracks")):
|
||||
if t.find("Name/EffectiveName").get("Value").strip().upper() == P["pad_track"].strip().upper():
|
||||
for c in t.findall(".//ArrangerAutomation/Events/AudioClip"):
|
||||
dis = c.find("Disabled")
|
||||
if dis is None or dis.get("Value") != "true":
|
||||
pads.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
|
||||
c.find("Name").get("Value")])
|
||||
tune = []
|
||||
for t in list(ls.find("Tracks")):
|
||||
if t.find("Name/EffectiveName").get("Value").strip().upper() == P["tune_track"].strip().upper():
|
||||
for c in t.findall(".//ArrangerAutomation/Events/MidiClip"):
|
||||
dis = c.find("Disabled")
|
||||
if dis is None or dis.get("Value") != "true":
|
||||
pg = c.find("ProgramChange")
|
||||
tune.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
|
||||
c.find("Name").get("Value"), int(pg.get("Value")) if pg is not None else None])
|
||||
return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events},
|
||||
"ltc": ltc}
|
||||
"ltc": ltc, "pads": pads, "tune": tune}
|
||||
out = {"live_set": live_set(), "bundles": {}}
|
||||
path = newest_als(folder(P["site"]))
|
||||
out["set"] = path
|
||||
if path:
|
||||
st = os.stat(path)
|
||||
key = [path, st.st_mtime, st.st_size, 2] # 2: the summary's layout
|
||||
key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], 4] # 4: the summary's layout
|
||||
cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json")
|
||||
data = None
|
||||
try:
|
||||
@@ -471,6 +497,19 @@ if path:
|
||||
pass
|
||||
out["mtime"] = st.st_mtime
|
||||
out["summary"] = data
|
||||
out["finished"] = {}
|
||||
lib = os.path.expanduser(P["tune_library"]) if P["tune_library"] else ""
|
||||
out["tune_library"] = {}
|
||||
for n in sorted(os.listdir(lib)) if lib and os.path.isdir(lib) else []:
|
||||
if n.endswith(".alc"):
|
||||
try:
|
||||
pg = ET.parse(gzip.open(os.path.join(lib, n))).getroot().find(".//MidiClip/ProgramChange")
|
||||
out["tune_library"][n[:-4]] = int(pg.get("Value")) if pg is not None else None
|
||||
except Exception:
|
||||
pass
|
||||
for f in P["finished"]:
|
||||
f = os.path.expanduser(f)
|
||||
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")}
|
||||
@@ -481,7 +520,9 @@ print(json.dumps(out))
|
||||
def ableton_params(rc, plan):
|
||||
return {"projects": rc.get("projects", ""), "site": rc.get("site_folder", ""), "folder": rc.get("folder_format", "{date} {loc} Project"),
|
||||
"date": datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"), "locations": rc.get("locations", []),
|
||||
"ltc_track": rc.get("ltc_track", "LTC")}
|
||||
"ltc_track": rc.get("ltc_track", "LTC"), "pad_track": rc.get("pad_track", "AMBIENT PADS"),
|
||||
"finished": rc.get("finished_folders", []), "tune_track": rc.get("tune_track", "WAVES TUNE"),
|
||||
"tune_library": rc.get("tune_library", "")}
|
||||
|
||||
|
||||
def ableton_look(cfg, rc, plan):
|
||||
@@ -563,6 +604,369 @@ def eval_ableton(rc, plan, look):
|
||||
return out
|
||||
|
||||
|
||||
# --- Ableton, live (AbletonOSC on the Tracks Mac) ------------------------------------------
|
||||
# AbletonOSC answers on port 11001 of whichever computer spoke to it last, and sends its listeners'
|
||||
# updates (Companion's volume and meter feedback) there too. So the set is read from the Tracks Mac itself
|
||||
# (127.0.0.1), and straight after, NA Ingest asks it for the tempo: from then on it answers Companion again.
|
||||
|
||||
LIVE_SCRIPT = r"""
|
||||
import json, socket, struct, time
|
||||
P = json.loads(%r)
|
||||
def pad(b):
|
||||
return b + b"\0" * (4 - len(b) %% 4)
|
||||
def msg(addr, *args):
|
||||
tags, data = ",", b""
|
||||
for a in args:
|
||||
if isinstance(a, bool):
|
||||
tags += "T" if a else "F"
|
||||
elif isinstance(a, int):
|
||||
tags, data = tags + "i", data + struct.pack(">i", a)
|
||||
elif isinstance(a, float):
|
||||
tags, data = tags + "f", data + struct.pack(">f", a)
|
||||
else:
|
||||
tags, data = tags + "s", data + pad(str(a).encode())
|
||||
return pad(addr.encode()) + pad(tags.encode()) + data
|
||||
def parse(b):
|
||||
def string(i):
|
||||
j = b.index(b"\0", i)
|
||||
return b[i:j].decode("utf-8", "replace"), (j // 4 + 1) * 4
|
||||
addr, i = string(0)
|
||||
tags, i = string(i)
|
||||
out = []
|
||||
for t in tags[1:]:
|
||||
if t == "i":
|
||||
out.append(struct.unpack(">i", b[i:i + 4])[0]); i += 4
|
||||
elif t == "f":
|
||||
out.append(struct.unpack(">f", b[i:i + 4])[0]); i += 4
|
||||
elif t == "s":
|
||||
s, i = string(i); out.append(s)
|
||||
elif t in "TF":
|
||||
out.append(t == "T")
|
||||
elif t == "N":
|
||||
out.append(None)
|
||||
return addr, out
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 21)
|
||||
try:
|
||||
s.bind(("127.0.0.1", 11001))
|
||||
except OSError:
|
||||
print(json.dumps({"error": "OSC PORT BUSY"})); raise SystemExit
|
||||
LIVE = ("127.0.0.1", 11000)
|
||||
def ask(reqs, timeout=4.0):
|
||||
# send [(addr, args)]; the replies keyed by (addr, track index)
|
||||
got = {}
|
||||
for k in range(0, len(reqs), 40):
|
||||
for a, args in reqs[k:k + 40]:
|
||||
s.sendto(msg(a, *args), LIVE)
|
||||
time.sleep(0.05)
|
||||
want = len(reqs)
|
||||
end = time.time() + timeout
|
||||
s.settimeout(0.3)
|
||||
while len(got) < want and time.time() < end:
|
||||
try:
|
||||
b, _ = s.recvfrom(65536)
|
||||
except socket.timeout:
|
||||
continue
|
||||
a, args = parse(b)
|
||||
if any(a == r[0] for r in reqs):
|
||||
got[(a, args[0] if args and a.startswith("/live/track/") else None)] = args
|
||||
return got
|
||||
out = {}
|
||||
g = ask([("/live/song/get/num_tracks", ()), ("/live/song/get/is_playing", ())])
|
||||
n = (g.get(("/live/song/get/num_tracks", None)) or [None])[0]
|
||||
if n is None:
|
||||
print(json.dumps({"error": "ABLETON NOT ANSWERING"})); raise SystemExit
|
||||
out["playing"] = bool((g.get(("/live/song/get/is_playing", None)) or [False])[0])
|
||||
props = ["track.name", "track.is_foldable", "track.is_grouped", "track.has_audio_output"]
|
||||
td = ask([("/live/song/get/track_data", (0, n) + tuple(props))]).get(("/live/song/get/track_data", None)) or []
|
||||
tracks = [dict(zip(("name", "group", "grouped", "audio"), td[i * 4:i * 4 + 4]), index=i) for i in range(len(td) // 4)]
|
||||
audio = [t for t in tracks if t["audio"] and not t["group"]]
|
||||
reqs = []
|
||||
for t in audio:
|
||||
reqs += [("/live/track/get/output_routing_type", (t["index"],)), ("/live/track/get/output_routing_channel", (t["index"],)),
|
||||
("/live/track/get/send", (t["index"], P["send_index"]))]
|
||||
g = ask(reqs, timeout=8.0)
|
||||
for t in audio:
|
||||
i = t["index"]
|
||||
t["type"] = (g.get(("/live/track/get/output_routing_type", i)) or [None, None])[1]
|
||||
t["channel"] = (g.get(("/live/track/get/output_routing_channel", i)) or [None, None])[1]
|
||||
snd = g.get(("/live/track/get/send", i))
|
||||
t["send"] = snd[2] if snd and len(snd) > 2 else None
|
||||
out["tracks"] = tracks
|
||||
if P.get("apply"):
|
||||
names = {t["index"]: t["name"] for t in tracks}
|
||||
done, skipped = [], []
|
||||
for ch in P["apply"]:
|
||||
i = ch["index"]
|
||||
if names.get(i) != ch["name"]:
|
||||
skipped.append(ch["name"]) # the set changed since the look: leave it for the next one
|
||||
continue
|
||||
if ch.get("type"):
|
||||
s.sendto(msg("/live/track/set/output_routing_type", i, ch["type"]), LIVE)
|
||||
time.sleep(0.15)
|
||||
if ch.get("channel") is not None and ch.get("type") is not None:
|
||||
s.sendto(msg("/live/track/set/output_routing_channel", i, ch["channel"]), LIVE)
|
||||
if ch.get("send") is not None:
|
||||
s.sendto(msg("/live/track/set/send", i, P["send_index"], float(ch["send"])), LIVE)
|
||||
done.append(ch["name"])
|
||||
time.sleep(0.05)
|
||||
out["done"], out["skipped"] = done, skipped
|
||||
print(json.dumps(out))
|
||||
"""
|
||||
|
||||
|
||||
def osc_message(addr, *args):
|
||||
"""One OSC message (strings and ints), for the hand-back to Companion."""
|
||||
def pad(b):
|
||||
return b + b"\0" * (4 - len(b) % 4)
|
||||
tags = "," + "".join("i" if isinstance(a, int) else "s" for a in args)
|
||||
data = b"".join(struct.pack(">i", a) if isinstance(a, int) else pad(str(a).encode()) for a in args)
|
||||
return pad(addr.encode()) + pad(tags.encode()) + data
|
||||
|
||||
|
||||
def hand_back(cfg, host):
|
||||
"""AbletonOSC answers the last computer that spoke to it: speak to it from here, where Companion listens."""
|
||||
ip = (((cfg.get("hosts") or {}).get(host) or {}).get("ssh") or "").rpartition("@")[2]
|
||||
if not ip:
|
||||
return
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.sendto(osc_message("/live/song/get/tempo"), (ip, 11000))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def live_look(cfg, rc, apply=None):
|
||||
host = rc.get("tracks_host")
|
||||
try:
|
||||
return run_remote(cfg, host, LIVE_SCRIPT % json.dumps({"send_index": rc.get("ears_send", 0), "apply": apply}),
|
||||
timeout=30)
|
||||
finally:
|
||||
hand_back(cfg, host)
|
||||
|
||||
|
||||
def stem_name(name):
|
||||
"""A track as the template knows it: "50-Keys 1 [2026-10-21 132128]" -> "KEYS 1"."""
|
||||
s = re.sub(r"\[.*?\]", " ", (name or "").upper())
|
||||
s = re.sub(r"^\s*\d+\s*-\s*", "", s)
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def template_for(routing, name):
|
||||
"""Where a stem goes: its own entry, else that of its family ("EG 13" goes where "EG 1".."EG 12" all go)."""
|
||||
n = stem_name(name)
|
||||
if n in routing:
|
||||
return routing[n]
|
||||
base = re.sub(r"\s*\d+$", "", n)
|
||||
fam = {tuple(v) for k, v in routing.items() if re.sub(r"\s*\d+$", "", k) == base}
|
||||
return list(fam.pop()) if len(fam) == 1 else None
|
||||
|
||||
|
||||
def mix_plan(rc, live):
|
||||
"""(routing Line, ears Line, changes): what is routed away from the template, which song stems have
|
||||
their in-ear send at -inf, and the changes that put both right."""
|
||||
routing, ears = Line(), Line()
|
||||
if live.get("error"):
|
||||
for line in (routing, ears):
|
||||
line.fault(WARN, live["error"], "%s: AbletonOSC on the Tracks Mac did not answer" % live["error"])
|
||||
return routing, ears, []
|
||||
table = {stem_name(k): v for k, v in (rc.get("routing") or {}).items()}
|
||||
skip = {norm(g) for g in rc.get("ears_skip_groups", [])}
|
||||
changes, top, n_route, n_ears, new = {}, None, 0, 0, []
|
||||
for t in live.get("tracks") or []:
|
||||
if t["group"] and not t["grouped"]:
|
||||
top = t["name"]
|
||||
continue
|
||||
if not t["grouped"]:
|
||||
top = None
|
||||
if t["group"] or not t["audio"] or t.get("type") is None:
|
||||
continue
|
||||
want = template_for(table, t["name"]) if table else None
|
||||
if want and [t["type"], t["channel"]] != list(want):
|
||||
changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]}).update(type=want[0], channel=want[1])
|
||||
routing.fault(ERR, "%s TO %s" % (short(stem_name(t["name"]), 12), (t["channel"] or t["type"]).upper()[:10]),
|
||||
"%s (%s) goes to %s %s; the template says %s %s" % (t["name"], top or "top level", t["type"],
|
||||
t["channel"], want[0], want[1]))
|
||||
elif want:
|
||||
n_route += 1
|
||||
elif table and top and norm(top) not in skip:
|
||||
new.append(t["name"])
|
||||
if top and norm(top) not in skip and t.get("send") is not None:
|
||||
if t["send"] <= 0.0:
|
||||
changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]})["send"] = 1.0
|
||||
ears.fault(ERR, "EARS OFF " + short(top, 12), "%s in %s: send A is at -inf" % (t["name"], top))
|
||||
else:
|
||||
n_ears += 1
|
||||
if new:
|
||||
routing.fault(WARN, "NEW STEM " + short(stem_name(new[0]), 12), "not in the routing template (add them in the "
|
||||
"settings): " + ", ".join(new[:12]))
|
||||
routing.info = "%d ROUTED" % n_route if table else "NO TEMPLATE"
|
||||
ears.info = "%d SENDS ON" % n_ears
|
||||
if live.get("playing"):
|
||||
routing.notes.append("Ableton is playing")
|
||||
return routing, ears, sorted(changes.values(), key=lambda change: change["index"])
|
||||
|
||||
|
||||
# --- Song keys --------------------------------------------------------------------------
|
||||
|
||||
NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||
KEY_AT_END = re.compile(r"-\s*([A-G](?:#|b|♯|♭)?m?)\s*$") # a locator: "3- JEHOVAH - B"
|
||||
KEY_AT_START = re.compile(r"^\s*([A-G](?:#|b|♯|♭)?m?)(?=\s|-|_|$)") # a pad: "D# - WARM - CHURCHFRONT PADS"
|
||||
FINISHED = re.compile(r"^(.*?)\s*\[\s*([A-G](?:#|b|♯|♭)?m?)\s*\]\s*(?:-\s*CUSTOM)?\s*$", re.I) # "JEHOVAH [Db]"
|
||||
|
||||
|
||||
def key_of(text):
|
||||
"""A key as (pitch class, minor): "Eb" and "D#" are the same key; "C#m" is minor; None if it is not a key."""
|
||||
m = re.match(r"^\s*([A-Ga-g])(#|b|♯|♭)?\s*(m|min|minor)?\s*$", text or "")
|
||||
if not m:
|
||||
return None
|
||||
pc = NOTES[m.group(1).upper()] + {"#": 1, "♯": 1, "b": -1, "♭": -1}.get(m.group(2) or "", 0)
|
||||
return pc % 12, bool(m.group(3))
|
||||
|
||||
|
||||
def same_key(a, b, mode=True):
|
||||
"""Two keys that sound the same (a pad has no mode: mode=False compares the root alone)."""
|
||||
ka, kb = key_of(a), key_of(b)
|
||||
return bool(ka and kb) and ka[0] == kb[0] and (not mode or ka[1] == kb[1])
|
||||
|
||||
|
||||
def finished_versions(look, title):
|
||||
"""The keys Finished Songs has `title` in: an exact name if there is one, else a longer one
|
||||
("CAN'T HELP BUT PRAISE" for "Can't Help But Praise (Live)"). Unfinished and z-parked folders do not count."""
|
||||
exact, near = [], []
|
||||
for names in (look.get("finished") or {}).values():
|
||||
for n in names or []:
|
||||
m = FINISHED.match(n)
|
||||
if not m or "UNFINISHED" in n.upper():
|
||||
continue
|
||||
(exact if norm(m.group(1)) == norm(title) else near if same(m.group(1), title) else []).append((n, m.group(2)))
|
||||
return exact or near
|
||||
|
||||
|
||||
def eval_keys(rc, plan, look):
|
||||
"""Every song in the key PCO gives it, by the set's locator, the pad under the song and Finished Songs."""
|
||||
line = Line()
|
||||
if not look.get("set"):
|
||||
date = ableton_params(rc, plan)["date"]
|
||||
line.fault(ERR, "NO SET %s" % date, "no set for %s, so no keys to check" % date)
|
||||
return line
|
||||
summary = look["summary"]
|
||||
groups = song_groups(summary)
|
||||
pads = summary.get("pads") or []
|
||||
folders = [os.path.basename(f.rstrip("/")) for f in look.get("finished") or {}]
|
||||
seen, ok = set(), 0
|
||||
for it in plan_songs(rc, plan):
|
||||
title, want = it["title"], it.get("key") or ""
|
||||
name = short(title, 14)
|
||||
if not key_of(want):
|
||||
line.fault(WARN, "NO KEY IN PCO " + short(title), "Planning Center has no key for %s" % title)
|
||||
continue
|
||||
gname = next((g for g in groups if same(title, g)), None)
|
||||
if (gname or norm(title), key_of(want)) in seen: # "Owe You Praise TAG" is Owe You Praise again
|
||||
continue
|
||||
seen.add((gname or norm(title), key_of(want)))
|
||||
bad, checked = False, 0
|
||||
loc = next((l for l in summary.get("locators") or [] if same(title, l["name"]) and KEY_AT_END.search(l["name"])), None)
|
||||
if loc:
|
||||
have = KEY_AT_END.search(loc["name"]).group(1)
|
||||
checked += 1
|
||||
if not same_key(have, want):
|
||||
bad = True
|
||||
line.fault(ERR, "%s %s NOT %s" % (name, have, want), "Planning Center has %s in %s; the set's locator \"%s\" says %s" % (
|
||||
title, want, loc["name"].strip(), have))
|
||||
audio = [c for t in groups.get(gname, []) for c in t["clips"]] if gname else []
|
||||
if audio:
|
||||
start, end = min(c[0] for c in audio), max(c[1] for c in audio)
|
||||
under = [p for p in pads if p[0] < end and p[1] > start and KEY_AT_START.match(p[2] or "")]
|
||||
if under:
|
||||
pad = max(under, key=lambda p: min(p[1], end) - max(p[0], start))
|
||||
have = KEY_AT_START.match(pad[2]).group(1)
|
||||
checked += 1
|
||||
if not same_key(have, want, mode=False):
|
||||
bad = True
|
||||
line.fault(ERR, "%s PAD %s NOT %s" % (name, have, want), "the pad under %s is %s (%s), but the song is in %s" % (
|
||||
gname, have, pad[2], want))
|
||||
if folders:
|
||||
versions = finished_versions(look, title)
|
||||
if not versions:
|
||||
line.notes.append("%s is not in %s" % (title, " or ".join(folders)))
|
||||
elif any(same_key(k, want) for _, k in versions):
|
||||
checked += 1
|
||||
else:
|
||||
line.fault(WARN, "%s ONLY %s" % (name, "/".join(sorted({k for _, k in versions}))),
|
||||
"%s has %s, none in %s" % (" / ".join(folders), ", ".join(n for n, _ in versions), want))
|
||||
bad = True
|
||||
if not checked and not bad:
|
||||
line.fault(WARN, "KEY UNCHECKED " + short(title), "no locator with a key, no pad under it and nothing in Finished Songs "
|
||||
"for %s (%s in PCO)" % (title, want))
|
||||
elif not bad:
|
||||
ok += 1
|
||||
line.info = "%d KEYS OK" % ok
|
||||
return line
|
||||
|
||||
|
||||
def tune_key(name):
|
||||
"""The key a Waves Tune clip sets: "Ebmaj" -> "Eb", "C#min" -> "C#m"; None for "OFF | Chromatic" or "LOOP CUE"."""
|
||||
m = re.match(r"^\s*([A-G](?:#|b)?)\s*(maj|min|m)?\b", name or "")
|
||||
return (m.group(1) + ("m" if m.group(2) in ("min", "m") else "")) if m else None
|
||||
|
||||
|
||||
def same_tune(clip_key, want):
|
||||
"""Waves Tune corrects to a scale: a minor song is fine on its relative major (Em on Gmaj)."""
|
||||
if same_key(clip_key, want):
|
||||
return True
|
||||
kc, kw = key_of(clip_key), key_of(want)
|
||||
return bool(kc and kw) and kw[1] and not kc[1] and (kw[0] + 3) % 12 == kc[0]
|
||||
|
||||
|
||||
def eval_tune(rc, plan, look):
|
||||
"""The Waves Tune clip at each song's start is the song's PCO key (and the program it sends is that
|
||||
key's program in the clip library, so a renamed clip is caught)."""
|
||||
line = Line()
|
||||
if not look.get("set"):
|
||||
date = ableton_params(rc, plan)["date"]
|
||||
line.fault(ERR, "NO SET %s" % date, "no set for %s" % date)
|
||||
return line
|
||||
summary = look["summary"]
|
||||
track = rc.get("tune_track", "WAVES TUNE")
|
||||
clips = sorted(summary.get("tune") or [], key=lambda c: c[0])
|
||||
if not clips:
|
||||
line.fault(ERR, "NO TUNE CLIPS", "no clips on the %s track" % track)
|
||||
return line
|
||||
library = look.get("tune_library") or {}
|
||||
groups = song_groups(summary)
|
||||
seen, ok = set(), 0
|
||||
for it in plan_songs(rc, plan):
|
||||
title, want = it["title"], it.get("key") or ""
|
||||
gname = next((g for g in groups if same(title, g)), None)
|
||||
audio = [c for t in groups.get(gname, []) for c in t["clips"]] if gname else []
|
||||
if not audio or not key_of(want) or gname in seen:
|
||||
continue # TRACKS and SONG KEYS say what is missing
|
||||
seen.add(gname)
|
||||
start = min(c[0] for c in audio)
|
||||
at = [c for c in clips if c[0] <= start + 1]
|
||||
bar = "bar %d" % (int(start // 4) + 1)
|
||||
name = short(title, 14)
|
||||
if not at:
|
||||
line.fault(ERR, "NO TUNE " + name, "no %s clip at or before %s, where %s starts" % (track, bar, gname))
|
||||
continue
|
||||
clip = at[-1]
|
||||
have = tune_key(clip[2])
|
||||
lib = next((n for n in library if tune_key(n) and same_tune(tune_key(n), want) and same_key(tune_key(n), have or "")), None)
|
||||
right = next((n for n in library if tune_key(n) and same_tune(tune_key(n), want)), "%smaj" % want)
|
||||
if not have or not same_tune(have, want):
|
||||
line.fault(ERR, "%s TUNE %s" % (name, have or clip[2][:6].upper()),
|
||||
"%s starts at %s on the \"%s\" clip, but the song is in %s: put the %s clip there instead" % (
|
||||
title, bar, clip[2], want, right))
|
||||
elif lib and library.get(lib) is not None and clip[3] is not None and clip[3] != library[lib]:
|
||||
line.fault(ERR, "%s TUNE PGM" % name, "the \"%s\" clip at %s sends program %d, but %s is program %d" % (
|
||||
clip[2], bar, clip[3], lib, library[lib]))
|
||||
else:
|
||||
ok += 1
|
||||
line.info = "%d SONGS IN KEY" % ok
|
||||
return line
|
||||
|
||||
|
||||
def eval_track_bundle(rc, look):
|
||||
line = Line()
|
||||
for loc, b in sorted((look.get("bundles") or {}).items()):
|
||||
@@ -1035,7 +1439,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6),
|
||||
resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6),
|
||||
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
|
||||
companion=lambda n: companion_page(cfg, n),
|
||||
companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc),
|
||||
prop_bundle=lambda plan: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps(
|
||||
{"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
|
||||
src.update(sources or {})
|
||||
@@ -1053,7 +1457,9 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
except Exception as e: # noqa: BLE001 - one source down must not hide the others
|
||||
plan = None
|
||||
failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")),
|
||||
("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
|
||||
("lights", rc.get("tracks_host")), ("keys", rc.get("tracks_host")),
|
||||
("tune", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
|
||||
("routing", rc.get("ableton_osc")), ("ears", rc.get("ableton_osc")),
|
||||
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
|
||||
("lineup", rc.get("lineup_url")), ("mics", True),
|
||||
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")),
|
||||
@@ -1085,6 +1491,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
try:
|
||||
al = src["ableton"](plan)
|
||||
lines.update(eval_ableton(rc, plan, al))
|
||||
lines["keys"] = eval_keys(rc, plan, al)
|
||||
lines["tune"] = eval_tune(rc, plan, al)
|
||||
if rc.get("ltc_note", "LTC"):
|
||||
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
|
||||
na_songs = song_ltc(rc, plan, al)
|
||||
@@ -1107,7 +1515,12 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
if rc.get("locations"):
|
||||
lines["track_bundle"] = eval_track_bundle(rc, al)
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed(["tracks", "mix_foh", "lights"] + (["track_bundle"] if rc.get("locations") else []), e)
|
||||
failed(["tracks", "mix_foh", "lights", "keys", "tune"] + (["track_bundle"] if rc.get("locations") else []), e)
|
||||
if rc.get("tracks_host") and rc.get("ableton_osc"):
|
||||
try:
|
||||
lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]())
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed(["routing", "ears"], e)
|
||||
if rc.get("lineup_url"):
|
||||
try:
|
||||
lines["lineup"] = eval_lineup(rc, plan, src["lineup"]())
|
||||
@@ -1234,6 +1647,16 @@ def run_action(cfg, action, chk=None, sleep=time.sleep):
|
||||
return "NO SET TO COPY"
|
||||
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
|
||||
return True
|
||||
if action == "ableton_mix":
|
||||
_, _, changes = mix_plan(rc, live_look(cfg, rc))
|
||||
if not changes:
|
||||
return True
|
||||
r = live_look(cfg, rc, apply=changes)
|
||||
if r.get("error"):
|
||||
return r["error"]
|
||||
log.info("FIX Ableton: %s put to the template / send A to 0 dB%s", ", ".join(r.get("done") or []) or "nothing",
|
||||
("; left (the set changed): " + ", ".join(r["skipped"])) if r.get("skipped") else "")
|
||||
return True if r.get("done") else "SET CHANGED"
|
||||
log.warning("FIX ready: unknown action %r", action)
|
||||
return False
|
||||
except ReadyError as e:
|
||||
|
||||
Reference in New Issue
Block a user