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:
2026-10-22 10:03:49 -04:00
co-authored by Claude Opus 5.5
parent 51a14140cc
commit f179df59f1
8 changed files with 923 additions and 21 deletions
+430 -7
View File
@@ -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: