PCO's song description names the lead ('Genesis lead', RESI marks the song, else the last before the
message), the lineup screen gives their VOX, and the Resi AJA's Lead Vocal channel (21) is read from
the card with Dante's own read-only query. Wrong: red, and the hold / auto-fix presses that VOX's AJA
button on page 13 and reads the card back. Two leads, a lead not on the lineup, a PCO Audio note naming
another VOX, or VOX 5+ are flagged only. Checked on 2026-10-25's real data: Genesis, VOX 4, AJA on VOX 4;
the Audio note says V2, so it is flagged and nothing switches. Applied on NA after the services.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2399 lines
118 KiB
Python
2399 lines
118 KiB
Python
#!/usr/bin/env python3
|
||
"""The READY page: the weekly worship checklist, checked against Planning Center.
|
||
|
||
Every couple of minutes this looks at this weekend's plan in Planning Center (PCO) and at
|
||
what is actually in place for it, and writes one verdict per checklist line into
|
||
snap.ready, which the READY page keys draw (check type "ready" in status_panel.py):
|
||
|
||
tracks the weekend's Ableton set exists and has a song group for every PCO song
|
||
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
|
||
light_timing every blinder flash and FX cue on the lighting track lands on the hit in the drum stem under it
|
||
(a hit a few ms before or after the cue, with the cue itself on a quiet spot, is named)
|
||
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
|
||
the tracks play at that song's start (worked out from the set's LTC clip and tempo map)
|
||
backgrounds every song's slides have a background (ProPresenter labels a slide with
|
||
its media's file name)
|
||
led_cross every Resolume column Sunday's slides call up holds content, on the Service deck
|
||
(slide -> macro -> RossTalk "CC page/row/col" to Companion -> Resolume column)
|
||
lineup the Band Lineup board (ProdDash) shows this plan and exactly the people on it
|
||
mics (a tap-to-confirm key) which mics this weekend needs, from PCO
|
||
track_bundle every location has this weekend's Ableton project folder on the shared drive
|
||
prop_bundle a ProPresenter bundle was saved to the locations' folder this week
|
||
resi_vox the Resi song (marked RESI in PCO, else the last song before the message) goes out to the locations
|
||
with its lead singer's mic: the lead from PCO ("Genesis lead"), their VOX number from the lineup
|
||
screen, and what the Resi AJA's "Lead Vocal" channel really receives, read from the card over Dante.
|
||
The hold presses that VOX's button on Companion's page 13.
|
||
start every service PCO lists this week (Sunday and special services such as First / Together
|
||
Tuesday) has its Ableton set; the hold builds the missing ones (ableton_start.py)
|
||
|
||
What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync
|
||
the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that
|
||
has none, or simply look again now.
|
||
|
||
Nothing here writes to Companion, ProPresenter or Ableton's set; the Ableton set is read
|
||
from disk on the Tracks Mac (nice'd, and only when it changed), and the whole look is
|
||
skipped while a HyperDeck is recording.
|
||
|
||
Stdlib only; runs on the macOS /usr/bin/python3 (3.9).
|
||
"""
|
||
import base64
|
||
import calendar
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import socket
|
||
import struct
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
from datetime import datetime, timedelta
|
||
|
||
log = logging.getLogger("status-panel")
|
||
|
||
OK, WARN, ERR = "ok", "warn", "err"
|
||
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||
MEDIA = re.compile(r"\.(mp4|mov|m4v|mxf|mpg|mpeg|avi|webm|jpg|jpeg|png|gif|heic|tif|tiff)$", re.I)
|
||
|
||
WAKE = threading.Event() # set by a hold: look again now instead of waiting for the interval
|
||
LAST = {} # the latest look (run_action needs the plan's date)
|
||
|
||
|
||
class ReadyError(Exception):
|
||
"""A source could not be read; the message is what the key shows."""
|
||
|
||
|
||
# --- Names ----------------------------------------------------------------------
|
||
|
||
def norm(text):
|
||
"""A title as words to compare: "Can't Help But Praise LM - [ Full ]" -> "CANT HELP BUT PRAISE LM",
|
||
"3- CAN'T HELP BUT PRAISE - Eb" -> "CANT HELP BUT PRAISE", "Rejoice Again (Live)" -> "REJOICE AGAIN"."""
|
||
s = (text or "").upper().replace("’", "'")
|
||
s = re.sub(r"\[.*?\]|\(.*?\)", " ", s)
|
||
s = re.sub(r"^\s*\d+\s*-\s*", "", s) # a locator's running number
|
||
s = re.sub(r"\s*-\s*[A-G](#|B)?M?\s*$", "", s) # a locator's key: " - Bb", "BLOOD- B"
|
||
s = re.sub(r"[^A-Z0-9 ]", "", s)
|
||
return " ".join(s.split())
|
||
|
||
|
||
def same(a, b):
|
||
"""The same song or item: equal, or one is the other plus more words ("COVERED BY THE BLOOD TAG")."""
|
||
a, b = norm(a), norm(b)
|
||
if not a or not b:
|
||
return False
|
||
return a == b or a.startswith(b + " ") or b.startswith(a + " ")
|
||
|
||
|
||
def short(title, n=16):
|
||
t = norm(title) or (title or "").upper()
|
||
return t if len(t) <= n else t[:n - 1].rstrip() + "."
|
||
|
||
|
||
def bullets(text):
|
||
"""The "- item" lines of a description."""
|
||
return [m.strip() for m in re.findall(r"^\s*[-•*]\s*(.+?)\s*$", text or "", re.M) if m.strip()]
|
||
|
||
|
||
def listed_names(text):
|
||
"""People named in a description like "730 930 None / 1130 Samantha Lopez, Bob Ray": the names,
|
||
without service times or "None"."""
|
||
names = []
|
||
for line in (text or "").splitlines():
|
||
line = re.sub(r"\b\d{3,4}\b", " ", line) # service times: 730, 1130
|
||
for part in re.split(r",|&|/|\band\b", line):
|
||
words = [w for w in part.split() if w.lower() not in ("none", "n/a", "na", "tbd", "-")]
|
||
if words and words[0][:1].isalpha():
|
||
names.append(" ".join(words))
|
||
return names
|
||
|
||
|
||
# --- Time -------------------------------------------------------------------------
|
||
|
||
def parse_iso(text):
|
||
"""PCO's "2026-10-18T13:30:00Z" as unix time (None if it is not one)."""
|
||
try:
|
||
return datetime.fromisoformat((text or "").replace("Z", "+00:00")).timestamp()
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def week_start(now, day="mon"):
|
||
"""Local midnight at the start of the checklist's week (taps from before it no longer count)."""
|
||
d = datetime.fromtimestamp(now)
|
||
back = (d.weekday() - DAYS.index(day)) % 7
|
||
start = datetime(d.year, d.month, d.day) - timedelta(days=back)
|
||
return time.mktime(start.timetuple())
|
||
|
||
|
||
# --- HTTP and SSH ------------------------------------------------------------------
|
||
|
||
def http_json(url, headers=None, method="GET", data=None, timeout=10):
|
||
req = urllib.request.Request(url, headers=headers or {}, method=method, data=data)
|
||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||
body = r.read()
|
||
return json.loads(body) if body.strip() else None
|
||
|
||
|
||
def ssh_argv(cfg, host):
|
||
"""The same login the watchdog's checks and fixes use (status_panel.ssh_cmd)."""
|
||
import status_panel
|
||
return status_panel.ssh_cmd(cfg, host)
|
||
|
||
|
||
def run_remote(cfg, host, script, timeout=60):
|
||
"""Run a Python script on another Mac (on stdin, at low priority), or here when `host` is this Mac
|
||
(NA Ingest's Google Drive is current; NA ProP's lagged a day behind, 2026-10-21); its last line is JSON."""
|
||
here = ((cfg.get("hosts") or {}).get(host) or {}).get("self")
|
||
argv = ["/usr/bin/nice", "-n", "19", "/usr/bin/python3", "-"] if here else ssh_argv(cfg, host) + ["nice -n 19 /usr/bin/python3 -"]
|
||
r = subprocess.run(argv, input=script, capture_output=True, text=True, timeout=timeout)
|
||
if r.returncode == 255:
|
||
raise ReadyError("%s UNREACHABLE" % host.upper())
|
||
lines = [ln for ln in r.stdout.strip().splitlines() if ln.strip()]
|
||
if r.returncode != 0 or not lines:
|
||
raise ReadyError("%s SCRIPT FAILED" % host.upper()) if not lines else ReadyError(lines[-1][:40])
|
||
return json.loads(lines[-1])
|
||
|
||
|
||
# --- Planning Center -----------------------------------------------------------------
|
||
|
||
PCO = "https://api.planningcenteronline.com/services/v2"
|
||
|
||
|
||
def pco_login(rc):
|
||
"""(Authorization header, service type) from ProdDash's own PCO settings on this Mac, so the
|
||
login lives in one place."""
|
||
path = os.path.expanduser(rc.get("pco_credentials", "~/Library/Application Support/ProdDash/modules.json"))
|
||
try:
|
||
mods = json.load(open(path))
|
||
except (OSError, ValueError):
|
||
raise ReadyError("NO PCO LOGIN")
|
||
c = (mods.get(rc.get("pco_module", "pco-plan")) or {}).get("config") or {}
|
||
if c.get("accessToken"):
|
||
auth = "Bearer " + c["accessToken"]
|
||
elif c.get("appId") and c.get("secret"):
|
||
auth = "Basic " + base64.b64encode(("%s:%s" % (c["appId"], c["secret"])).encode()).decode()
|
||
else:
|
||
raise ReadyError("NO PCO LOGIN")
|
||
st = rc.get("pco_service_type") or (c.get("serviceTypes") or [None])[0] or c.get("serviceTypeId")
|
||
if not st:
|
||
raise ReadyError("NO SERVICE TYPE")
|
||
return auth, str(st)
|
||
|
||
|
||
def pco_get(auth, path, pages=10):
|
||
"""One PCO list (data, included), following "next" for at most `pages` pages (the plan lists
|
||
want one page: following them would walk the church's whole history)."""
|
||
url, data, included = PCO + path, [], []
|
||
while url and pages > 0:
|
||
d = http_json(url, {"Authorization": auth}, timeout=15)
|
||
data += d.get("data") if isinstance(d.get("data"), list) else [d.get("data")]
|
||
included += d.get("included") or []
|
||
url, pages = (d.get("links") or {}).get("next"), pages - 1
|
||
return data, included
|
||
|
||
|
||
def pick_plan(plans, now):
|
||
"""Today's plan, else the next one (from 12 hours ago on) - the plan ProdDash's lineup picks too."""
|
||
dated = []
|
||
for p in plans:
|
||
t = parse_iso((p.get("attributes") or {}).get("sort_date"))
|
||
if t is not None:
|
||
dated.append((t, p))
|
||
today = datetime.fromtimestamp(now).date()
|
||
for t, p in dated:
|
||
if datetime.fromtimestamp(t).date() == today:
|
||
return p, t
|
||
upcoming = sorted(((t, p) for t, p in dated if t >= now - 12 * 3600), key=lambda tp: tp[0])
|
||
return (upcoming[0][1], upcoming[0][0]) if upcoming else (None, None)
|
||
|
||
|
||
def pco_plan(rc, now, service_type=None, team=True):
|
||
"""This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}.
|
||
`service_type` reads another location's plan for the same weekend (its team is not needed)."""
|
||
auth, st = pco_login(rc)
|
||
if service_type:
|
||
st = str(service_type)
|
||
try:
|
||
future, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1)
|
||
past, _ = pco_get(auth, "/service_types/%s/plans?filter=past&order=-sort_date&per_page=2" % st, pages=1)
|
||
except OSError as e:
|
||
raise ReadyError("PCO UNREACHABLE") from e
|
||
plan, t = pick_plan(future + past, now)
|
||
if plan is None:
|
||
raise ReadyError("NO PLAN IN PCO")
|
||
pid = plan["id"]
|
||
items, inc = pco_get(auth, "/service_types/%s/plans/%s/items?include=item_notes&per_page=100" % (st, pid))
|
||
notes = {i["id"]: i.get("attributes") or {} for i in inc if i.get("type") == "ItemNote"}
|
||
out_items = []
|
||
for it in items:
|
||
a = it.get("attributes") or {}
|
||
ns, note_ids = {}, {}
|
||
for ref in ((it.get("relationships") or {}).get("item_notes") or {}).get("data") or []:
|
||
n = notes.get(ref["id"])
|
||
if n:
|
||
ns[n.get("category_name") or ""] = n.get("content") or ""
|
||
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, "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 = []
|
||
for m in members:
|
||
a = m.get("attributes") or {}
|
||
tref = ((m.get("relationships") or {}).get("team") or {}).get("data") or {}
|
||
team.append({"name": a.get("name") or "", "position": a.get("team_position_name") or "",
|
||
"team": teams.get(tref.get("id"), ""), "status": a.get("status") or ""})
|
||
a = plan.get("attributes") or {}
|
||
return {"id": pid, "service_type": st, "title": a.get("title") or "", "dates": a.get("dates") or "", "time": t,
|
||
"items": out_items, "team": team}
|
||
|
||
|
||
def plan_songs(rc, plan):
|
||
"""The songs the band plays: PCO songs during the service, minus any the site skips."""
|
||
skip = {norm(s) for s in rc.get("skip_songs", [])}
|
||
return [it for it in plan["items"] if it["type"] == "song" and it["position"] in ("during", "")
|
||
and norm(it["title"]) not in skip]
|
||
|
||
|
||
def declined(member):
|
||
return member["status"] in ("D", "Declined")
|
||
|
||
|
||
# --- ProPresenter ----------------------------------------------------------------------
|
||
|
||
def flatten_playlists(nodes, prefix=()):
|
||
"""[(api path "2/3", name)] for every playlist in the /v1/playlists tree."""
|
||
out = []
|
||
for i, node in enumerate(nodes or []):
|
||
idx = (node.get("id") or {}).get("index", i)
|
||
path = prefix + (str(idx),)
|
||
if node.get("field_type") == "group":
|
||
out += flatten_playlists(node.get("children"), path)
|
||
else:
|
||
out.append(("/".join(path), (node.get("id") or {}).get("name") or ""))
|
||
return out
|
||
|
||
|
||
def prop_look(rc, plan):
|
||
"""{"playlist": name, "items": [{"name", "type", "pres": {...} or None}]} for the plan's playlist."""
|
||
base = rc.get("prop_url", "").rstrip("/")
|
||
if not base:
|
||
raise ReadyError("NO PROP ADDRESS")
|
||
try:
|
||
tree = http_json(base + "/v1/playlists", timeout=6)
|
||
except (OSError, ValueError) as e:
|
||
raise ReadyError("PROP UNREACHABLE") from e
|
||
dates = (plan.get("dates") or "").lower()
|
||
found = [(p, n) for p, n in flatten_playlists(tree) if dates and dates in n.lower()]
|
||
if not found:
|
||
return {"playlist": None, "items": []}
|
||
path, name = found[-1]
|
||
data = http_json(base + "/v1/playlist/" + "/".join(urllib.parse.quote(x) for x in path.split("/")), timeout=6)
|
||
items, cache = [], {}
|
||
for it in (data or {}).get("items") or []:
|
||
uuid = (it.get("presentation_info") or {}).get("presentation_uuid")
|
||
pres = None
|
||
if uuid:
|
||
if uuid not in cache:
|
||
try:
|
||
p = (http_json(base + "/v1/presentation/" + uuid, timeout=6) or {}).get("presentation") or {}
|
||
slides = [s for g in p.get("groups") or [] for s in g.get("slides") or []]
|
||
cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"), "path": p.get("presentation_path"),
|
||
"timeline": bool(p.get("has_timeline")), "cues": p.get("total_cues") or 0,
|
||
"slides": [{"enabled": s.get("enabled", True), "label": s.get("label") or "",
|
||
"text": s.get("text") or "", "notes": s.get("notes") or ""} for s in slides]}
|
||
except (OSError, ValueError):
|
||
cache[uuid] = None
|
||
pres = cache[uuid]
|
||
items.append({"name": (it.get("id") or {}).get("name") or "", "type": it.get("type") or "",
|
||
"pco": bool(it.get("is_pco")), "pres": pres})
|
||
return {"playlist": name, "items": items}
|
||
|
||
|
||
def match_prop(pco_items, prop_items):
|
||
"""{index of a PCO item: its ProPresenter item}. By name; a PCO item the name does not find takes
|
||
the playlist item in the same place when the playlist came from PCO (ProPresenter names linked
|
||
items after the presentation: PCO "Message" is ProPresenter "Message Bumper")."""
|
||
want = [it for it in pco_items if it["type"] != "header"]
|
||
have = [it for it in prop_items if it["type"] != "header"]
|
||
found, used = {}, set()
|
||
for i, it in enumerate(want):
|
||
for j, p in enumerate(have):
|
||
if j not in used and same(it["title"], p["name"]):
|
||
found[i] = p
|
||
used.add(j)
|
||
break
|
||
if len(want) == len(have):
|
||
for i in range(len(want)):
|
||
if i not in found and i not in used and have[i]["pco"]:
|
||
found[i] = have[i]
|
||
used.add(i)
|
||
return want, found
|
||
|
||
|
||
def eval_prop(rc, plan, prop):
|
||
"""The prop, timecode and backgrounds lines."""
|
||
out = {"prop": Line(), "timecode": Line(), "backgrounds": Line()}
|
||
if prop["playlist"] is None:
|
||
for line in out.values():
|
||
line.fault(ERR, "NO PLAYLIST %s" % short(plan.get("dates"), 14), "ProPresenter has no playlist named for %s" % plan.get("dates"))
|
||
return out
|
||
want, found = match_prop(plan["items"], prop["items"])
|
||
songs = {id(s) for s in plan_songs(rc, plan)}
|
||
lists = rc.get("prop_lists", [])
|
||
for i, it in enumerate(want):
|
||
p = found.get(i)
|
||
if p is None:
|
||
out["prop"].fault(ERR, "MISSING " + short(it["title"]), "%s is in PCO but not in the ProPresenter playlist" % it["title"])
|
||
continue
|
||
pres = p["pres"]
|
||
if pres is None:
|
||
continue
|
||
slides = [s for s in pres["slides"] if s["enabled"]]
|
||
for rule in lists:
|
||
if rule.get("match", "").lower() not in it["title"].lower():
|
||
continue
|
||
tag = rule.get("short") or short(it["title"], 10)
|
||
if rule.get("kind") == "count":
|
||
want_n = bullets(it["description"])
|
||
if want_n and len(slides) < len(want_n):
|
||
out["prop"].fault(ERR, "%s %d/%d SLIDES" % (tag, len(slides), len(want_n)),
|
||
"PCO lists %d for %s, ProPresenter has %d slides" % (len(want_n), it["title"], len(slides)))
|
||
noted = {norm(b) for s in slides for b in bullets(s["notes"])}
|
||
if want_n and noted and not {norm(b) for b in want_n} <= noted:
|
||
out["prop"].fault(WARN, "%s SLIDES OLD?" % tag,
|
||
"%s: the slide notes list %s; PCO lists %s" % (
|
||
it["title"], ", ".join(sorted(noted)), ", ".join(want_n)))
|
||
elif rule.get("kind") == "names":
|
||
seen = " ".join((s["label"] + " " + s["text"]).lower() for s in slides)
|
||
for name in listed_names(it["description"]):
|
||
first = name.split()[0].lower()
|
||
if not re.search(r"\b%s\b" % re.escape(first), seen):
|
||
out["prop"].fault(ERR, "%s: NO %s" % (tag, short(name.split()[0], 10)),
|
||
"%s: PCO lists %s, no slide in %s names them" % (it["title"], name, pres["name"]))
|
||
if id(it) in songs:
|
||
if not pres["timeline"] or not pres["cues"]:
|
||
out["timecode"].fault(ERR, "NO TC " + short(it["title"]), "%s has no timeline in ProPresenter" % pres["name"])
|
||
# A background plays on until the next one, so one is enough; none at all is the gap.
|
||
if not any(MEDIA.search(s["label"].strip()) for s in slides):
|
||
out["backgrounds"].fault(ERR, "NO BG " + short(it["title"]), "no slide of %s has a background" % (
|
||
pres["name"] or p["name"]))
|
||
n_songs = len(songs)
|
||
out["prop"].info = "%d ITEMS" % len(want)
|
||
out["timecode"].info = "%d SONGS" % n_songs
|
||
out["backgrounds"].info = "%d SONGS" % n_songs
|
||
return out
|
||
|
||
|
||
# --- Ableton (read on the Tracks Mac) ------------------------------------------------------
|
||
|
||
ALS_SCRIPT = r'''
|
||
import glob, gzip, json, os, re, sys
|
||
import xml.etree.ElementTree as ET
|
||
P = json.loads(%r)
|
||
def live_set():
|
||
logs = sorted(glob.glob(os.path.expanduser("~/Library/Preferences/Ableton/Live*/Log.txt")), key=os.path.getmtime)
|
||
path = None
|
||
if logs:
|
||
with open(logs[-1], "rb") as f:
|
||
text = f.read().decode("utf-8", "replace")
|
||
for m in re.finditer(r'info: Loading document "(.*)"', text):
|
||
if "/Templates/" not in m.group(1):
|
||
path = m.group(1)
|
||
return path
|
||
def folder(loc):
|
||
return os.path.join(os.path.expanduser(P["projects"]), loc, P["folder"].format(date=P["date"], loc=loc))
|
||
def newest_als(d):
|
||
found = [os.path.join(d, n) for n in os.listdir(d) if n.endswith(".als") and not n.startswith(".")] if os.path.isdir(d) else []
|
||
return max(found, key=os.path.getmtime) if found else None
|
||
def summary(path):
|
||
root = ET.parse(gzip.open(path)).getroot()
|
||
ls = root.find("LiveSet")
|
||
tracks = []
|
||
for t in list(ls.find("Tracks")):
|
||
dc = t.find("DeviceChain")
|
||
g = t.find("TrackGroupId")
|
||
vol = dc.find("Mixer/Volume/Manual") if dc is not None else None
|
||
tgt = dc.find("AudioOutputRouting/Target") if dc is not None else None
|
||
up = dc.find("AudioOutputRouting/UpperDisplayString") if dc is not None else None
|
||
lo = dc.find("AudioOutputRouting/LowerDisplayString") if dc is not None else None
|
||
clips = []
|
||
for c in t.findall(".//ArrangerAutomation/Events/AudioClip") + t.findall(".//ArrangerAutomation/Events/MidiClip"):
|
||
dis = c.find("Disabled")
|
||
if dis is not None and dis.get("Value") == "true":
|
||
continue
|
||
s, e = c.find("CurrentStart"), c.find("CurrentEnd")
|
||
notes = len(c.findall(".//MidiNoteEvent")) if c.tag == "MidiClip" else None
|
||
clips.append([float(s.get("Value")), float(e.get("Value")), notes])
|
||
tracks.append({"id": t.get("Id"), "kind": t.tag, "name": t.find("Name/EffectiveName").get("Value"),
|
||
"group": g.get("Value") if g is not None else "-1",
|
||
"vol": float(vol.get("Value")) if vol is not None else None,
|
||
"ext": bool(tgt is not None and tgt.get("Value", "").startswith("AudioOut/External")),
|
||
"out": " ".join(x.get("Value") for x in (up, lo) if x is not None and x.get("Value")),
|
||
"clips": clips})
|
||
locs = [{"name": l.find("Name").get("Value"), "time": float(l.find("Time").get("Value"))}
|
||
for l in ls.findall("Locators/Locators/Locator")]
|
||
mt = ls.find("MasterTrack")
|
||
tempo = mt.find("DeviceChain/Mixer/Tempo")
|
||
target = tempo.find("AutomationTarget").get("Id")
|
||
events = []
|
||
for env in mt.findall("AutomationEnvelopes/Envelopes/AutomationEnvelope"):
|
||
if env.find("EnvelopeTarget/PointeeId").get("Value") == target:
|
||
events = [[float(e.get("Time")), float(e.get("Value"))] for e in env.findall("Automation/Events/FloatEvent")]
|
||
ltc = None
|
||
for t in list(ls.find("Tracks")):
|
||
if t.find("Name/EffectiveName").get("Value").strip().upper() == P["ltc_track"].strip().upper():
|
||
for c in t.findall(".//ArrangerAutomation/Events/AudioClip"):
|
||
g = lambda p: c.find(p).get("Value") if c.find(p) is not None else None
|
||
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])
|
||
cues, hit_src = [], []
|
||
for t in list(ls.find("Tracks")):
|
||
name = t.find("Name/EffectiveName").get("Value").strip()
|
||
if name.upper() == P["lighting_track"].strip().upper():
|
||
for c in t.findall(".//ArrangerAutomation/Events/MidiClip"):
|
||
if c.find("Disabled") is not None and c.find("Disabled").get("Value") == "true":
|
||
continue
|
||
start, end = float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value"))
|
||
offset = float(c.find("Loop/LoopStart").get("Value"))
|
||
for kt in c.iter("KeyTrack"):
|
||
k = int(kt.find("MidiKey").get("Value"))
|
||
if k not in P["hit_notes"]:
|
||
continue
|
||
for n in kt.iter("MidiNoteEvent"):
|
||
b = start + float(n.get("Time")) - offset
|
||
if n.get("IsEnabled") != "false" and start <= b < end:
|
||
cues.append([b, k])
|
||
stem = re.sub(r"^\s*\d+\s*-\s*", "", re.sub(r"\[.*?\]", " ", name.upper())).strip()
|
||
if t.tag == "AudioTrack" and any(stem.startswith(h.upper()) for h in P["hit_stems"]):
|
||
for c in t.findall(".//ArrangerAutomation/Events/AudioClip"):
|
||
# a deactivated clip counts: the drummer plays live, and the stem still says where the hits are
|
||
fr = c.find("SampleRef/FileRef")
|
||
rel = fr.find("RelativePath").get("Value") if fr.find("RelativePath") is not None else ""
|
||
absp = fr.find("Path").get("Value") if fr.find("Path") is not None else ""
|
||
f = os.path.join(os.path.dirname(path), rel) if rel and os.path.exists(os.path.join(os.path.dirname(path), rel)) else absp
|
||
hit_src.append([t.get("Id"), float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
|
||
f, c.find("IsWarped").get("Value") == "true", float(c.find("Loop/LoopStart").get("Value"))])
|
||
return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events},
|
||
"ltc": ltc, "pads": pads, "tune": tune, "cues": cues, "hit_src": hit_src}
|
||
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, P["pad_track"], P["tune_track"], P["lighting_track"], P["hit_notes"], P["hit_stems"], 6]
|
||
cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json")
|
||
data = None
|
||
try:
|
||
c = json.load(open(cache))
|
||
if c.get("key") == key:
|
||
data = c["data"]
|
||
except Exception:
|
||
pass
|
||
if data is None:
|
||
data = summary(path)
|
||
try:
|
||
with open(cache + ".tmp", "w") as f:
|
||
json.dump({"key": key, "data": data}, f)
|
||
os.replace(cache + ".tmp", cache)
|
||
except OSError:
|
||
pass
|
||
out["mtime"] = st.st_mtime
|
||
out["summary"] = data
|
||
out["finished"] = {}
|
||
out["light_names"] = {}
|
||
llib = os.path.expanduser(P["light_library"]) if P["light_library"] else ""
|
||
for n in sorted(os.listdir(llib)) if llib and os.path.isdir(llib) else []:
|
||
m = re.match(r"^(\d+)\s+(.+)\.alc$", n)
|
||
if m:
|
||
out["light_names"][m.group(1)] = m.group(2)
|
||
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")}
|
||
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"), "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", ""), "lighting_track": rc.get("lighting_track", "LIGHTING CUES"),
|
||
"hit_notes": rc.get("light_hit_notes", list(range(30, 40))), "hit_stems": rc.get("hit_stems", ["DRUMS"]),
|
||
"light_library": rc.get("light_library", "")}
|
||
|
||
|
||
def ableton_look(cfg, rc, plan):
|
||
host = rc.get("tracks_host")
|
||
if not host or not rc.get("projects") or not rc.get("site_folder"):
|
||
raise ReadyError("NO TRACKS SETUP")
|
||
return run_remote(cfg, host, ALS_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=rc.get("remote_timeout", 90))
|
||
|
||
|
||
def song_groups(summary):
|
||
"""{top-level group track name: [its audio tracks, at any depth]}."""
|
||
tracks = summary["tracks"]
|
||
by_id = {t["id"]: t for t in tracks}
|
||
|
||
def top(t):
|
||
seen = set()
|
||
while t["group"] not in ("-1", None) and t["group"] in by_id and t["id"] not in seen:
|
||
seen.add(t["id"])
|
||
t = by_id[t["group"]]
|
||
return t
|
||
groups = {t["name"]: [] for t in tracks if t["kind"] == "GroupTrack" and t["group"] in ("-1", None)}
|
||
for t in tracks:
|
||
if t["kind"] == "AudioTrack" and t["group"] not in ("-1", None):
|
||
g = top(t)
|
||
if g is not t and g["name"] in groups:
|
||
groups[g["name"]].append(t)
|
||
return groups
|
||
|
||
|
||
def eval_ableton(rc, plan, look):
|
||
out = {"tracks": Line(), "mix_foh": Line(), "lights": Line()}
|
||
date = ableton_params(rc, plan)["date"]
|
||
if not look.get("set"):
|
||
for name, line in out.items():
|
||
line.fault(ERR, "NO SET %s" % date, "no %s in %s/%s" % (
|
||
rc.get("folder_format", "{date} {loc} Project").format(date=date, loc=rc.get("site_folder")),
|
||
rc.get("projects"), rc.get("site_folder")))
|
||
return out
|
||
summary = look["summary"]
|
||
groups = song_groups(summary)
|
||
lighting = next((t for t in summary["tracks"] if norm(t["name"]) == norm(rc.get("lighting_track", "LIGHTING CUES"))), None)
|
||
cues = [c for c in (lighting or {}).get("clips", []) if c[2]] if lighting else []
|
||
songs = plan_songs(rc, plan)
|
||
unmixed = 0
|
||
for it in songs:
|
||
gname = next((g for g in groups if same(it["title"], g)), None)
|
||
if gname is None:
|
||
out["tracks"].fault(ERR, "NO TRACKS " + short(it["title"]), "%s has no song group in %s" % (
|
||
it["title"], os.path.basename(look["set"])))
|
||
continue
|
||
audio = [t for t in groups[gname] if t["clips"]]
|
||
if not audio:
|
||
out["tracks"].fault(ERR, "EMPTY " + short(it["title"]), "the %s group has no audio clips" % gname)
|
||
continue
|
||
vols = [t["vol"] for t in audio if t["vol"] is not None]
|
||
if vols and all(abs(v - 1.0) < 1e-6 for v in vols):
|
||
unmixed += 1
|
||
out["mix_foh"].fault(WARN, "NOT MIXED " + short(it["title"]), "every one of the %d tracks of %s is at 0 dB" % (len(vols), gname))
|
||
stray = [t for t in audio if not t["ext"]]
|
||
if stray:
|
||
out["mix_foh"].fault(ERR, "TO MASTER " + short(it["title"]), "%s: %s go to %s, not out to the console" % (
|
||
gname, ", ".join(t["name"] for t in stray[:4]), stray[0]["out"] or "Master"))
|
||
start = min(c[0] for t in audio for c in t["clips"])
|
||
end = max(c[1] for t in audio for c in t["clips"])
|
||
if lighting is None:
|
||
pass
|
||
elif not any(c[0] < end and c[1] > start for c in cues):
|
||
out["lights"].fault(ERR, "NO LIGHTS " + short(it["title"]), "no clip on %s between beats %g and %g (%s)" % (
|
||
lighting["name"], start, end, gname))
|
||
if lighting is None:
|
||
out["lights"].fault(ERR, "NO CUE TRACK", "no track called %s in the set" % rc.get("lighting_track", "LIGHTING CUES"))
|
||
live = look.get("live_set")
|
||
if live and os.path.basename(live) != os.path.basename(look["set"]):
|
||
out["tracks"].fault(WARN, "LIVE HAS OLD SET", "Ableton last opened %s, not %s" % (os.path.basename(live), os.path.basename(look["set"])))
|
||
saved = datetime.fromtimestamp(look.get("mtime") or 0).strftime("%a %H:%M").upper()
|
||
out["tracks"].info = "%d SONGS" % len(songs)
|
||
out["mix_foh"].info = "SAVED " + saved
|
||
out["lights"].info = "%d CUES" % len(cues)
|
||
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"])
|
||
|
||
|
||
# --- Lighting cue timing -------------------------------------------------------------------
|
||
|
||
HIT_SCRIPT = r"""
|
||
import aifc, audioop, json, wave
|
||
P = json.loads(%r)
|
||
FRAME = 0.0025
|
||
def attack(env, i):
|
||
# how far the drums climb within 10 ms of frame i, from the quietest point in the 20 ms before it
|
||
after = max(env[i:i + 4] or [0])
|
||
return max(0, after - min(env[max(0, i - 8):i] or [after]))
|
||
def look(path, sec, span):
|
||
aiff = path.lower().endswith((".aif", ".aiff"))
|
||
f = aifc.open(path, "rb") if aiff else wave.open(path, "rb")
|
||
try:
|
||
sr, w, ch = f.getframerate(), f.getsampwidth(), f.getnchannels()
|
||
a = max(0, int((sec - span - 0.05) * sr))
|
||
f.setpos(a)
|
||
data = f.readframes(int((2 * span + 0.1) * sr))
|
||
finally:
|
||
f.close()
|
||
if aiff:
|
||
data = audioop.byteswap(data, w) # AIFF is big-endian
|
||
hop = int(FRAME * sr) * w * ch
|
||
env = [audioop.rms(data[i:i + hop], w) for i in range(0, len(data) - hop + 1, hop)]
|
||
t0 = a / sr - sec
|
||
near = [i for i in range(len(env)) if abs(t0 + i * FRAME) <= span]
|
||
if not near:
|
||
return None
|
||
att = [attack(env, i) for i in range(len(env))]
|
||
best = max(near, key=lambda i: att[i])
|
||
# what is under the cue: the stems' hits start up to ~15 ms after the grid, so look that far after it
|
||
at = max([att[i] for i in near if -0.010 <= t0 + i * FRAME <= 0.025] or [0])
|
||
# where that hit starts: the first frame that climbs halfway from the quiet before it to its peak
|
||
lo, hi = min(env[max(0, best - 8):best] or [0]), max(env[best:best + 4] or [0])
|
||
start = next((j for j in range(max(0, best - 8), best + 4) if env[j] >= lo + 0.5 * (hi - lo)), best)
|
||
return [round(t0 + start * FRAME, 4), att[best], at]
|
||
out = []
|
||
for path, sec, span in P["cues"]:
|
||
try:
|
||
out.append(look(path, sec, span))
|
||
except Exception as e:
|
||
out.append(str(e)[:60])
|
||
print(json.dumps(out))
|
||
"""
|
||
HITS = {} # (set, its mtime) -> the hit list, so an unchanged set is not listened to again
|
||
MOVED = {} # (set, its mtime) -> beats a hold moved in Live, until the set is saved
|
||
|
||
|
||
def cue_times(rc, summary):
|
||
"""[(beat, note, song group, file, second in the file, search span, a 16th in seconds)] for every flash and FX
|
||
cue over a drum stem. The search reaches out to almost a full 16th either way."""
|
||
groups = song_groups(summary)
|
||
owner = {t["id"]: g for g, members in groups.items() for t in members}
|
||
tempo = summary["tempo"]
|
||
out, seen, wanted = [], set(), set(rc.get("light_hit_notes", range(30, 40)))
|
||
for beat, note in sorted(summary.get("cues") or []):
|
||
if beat in seen or note not in wanted:
|
||
continue # a flash and an FX on the same 16th: one hit
|
||
src = next((h for h in summary.get("hit_src") or [] if h[1] <= beat < h[2] and not h[4]), None)
|
||
if not src:
|
||
continue
|
||
seen.add(beat)
|
||
sec = seconds_at(tempo, beat) - seconds_at(tempo, src[1]) + src[5]
|
||
sixteenth = seconds_at(tempo, beat + 0.25) - seconds_at(tempo, beat)
|
||
out.append((beat, note, owner.get(src[0], ""), src[3], sec, min(0.95 * sixteenth, 0.25), sixteenth))
|
||
return out
|
||
|
||
|
||
def bar_of(beat):
|
||
"""Live's position: beat 1315.75 -> "329.4.4" (4/4)."""
|
||
return "%d.%d.%d" % (beat // 4 + 1, beat % 4 + 1, (beat % 1) * 4 + 1)
|
||
|
||
|
||
def off_cues(rc, cues, hits):
|
||
"""[(cue, ms off against the song's own cues on solid hits)] for cues on a quiet spot with a strong hit near.
|
||
A cue that already sits on a solid hit is never named, even with a stronger hit close by (a double hit)."""
|
||
tol = rc.get("light_tolerance_ms", 25) / 1000.0
|
||
strengths = {}
|
||
for c, h in zip(cues, hits):
|
||
if isinstance(h, list):
|
||
strengths.setdefault(c[3], []).append(h[1])
|
||
typical = {f: sorted(v)[len(v) // 2] for f, v in strengths.items()}
|
||
solid = lambda c, h: h[2] >= 0.5 * typical.get(c[3], 0) or (h[1] > 0 and h[2] >= 0.6 * h[1])
|
||
# each song's stem starts its hits a few ms after the grid: what its cues on solid hits show is "on time"
|
||
base = {}
|
||
for c, h in zip(cues, hits):
|
||
if isinstance(h, list) and solid(c, h):
|
||
base.setdefault(c[2], []).append(h[0])
|
||
base = {song: sorted(v)[len(v) // 2] for song, v in base.items()}
|
||
out = []
|
||
for c, h in zip(cues, hits):
|
||
if not isinstance(h, list):
|
||
continue
|
||
dt = h[0] - base.get(c[2], 0.0)
|
||
if abs(dt) > tol and h[1] >= 0.4 * typical.get(c[3], 0) and h[1] > 0 and not solid(c, h):
|
||
out.append((c, dt))
|
||
return out
|
||
|
||
|
||
def eval_light_timing(rc, look, hits):
|
||
"""Cues whose hit is clearly off (see off_cues), named with song and bar; cues a hold moved in Live
|
||
count as done until the set is saved and listened to again."""
|
||
line = Line()
|
||
if not look.get("set"):
|
||
line.fault(ERR, "NO SET", "no set to check")
|
||
return line
|
||
cues = cue_times(rc, look["summary"])
|
||
if not cues:
|
||
line.info = "NO CUES ON DRUMS"
|
||
return line
|
||
names = look.get("light_names") or {}
|
||
moved = set(MOVED.get((look.get("set"), look.get("mtime"))) or [])
|
||
off = [(c, dt) for c, dt in off_cues(rc, cues, hits) if c[0] not in moved]
|
||
said = []
|
||
for (beat, note, song, _, _, _, sixteenth), dt in off:
|
||
how = "%d ms %s the hit" % (abs(dt) * 1000, "before" if dt > 0 else "after")
|
||
if abs(dt) >= 0.75 * sixteenth:
|
||
how += " (about a 16th)"
|
||
said.append("%s bar %s: %s is %s" % (song or "?", bar_of(beat), names.get(str(note), "note %d" % note), how))
|
||
if off:
|
||
line.fault(WARN, "%d CUES OFF" % len(off) if len(off) > 1 else "CUE OFF %s" % bar_of(off[0][0][0]), said[0])
|
||
line.notes += said[1:]
|
||
if moved:
|
||
line.fault(WARN, "SAVE SET (%d MOVED)" % len(moved), "a hold moved %d cue%s onto the hit in Live: save the set "
|
||
"(Cmd+S), or Cmd+Z in Live to take it back" % (len(moved), "" if len(moved) == 1 else "s"))
|
||
broken = sorted({os.path.basename(c[3]) for c, h in zip(cues, hits) if not isinstance(h, list)})
|
||
line.notes += ["could not read %s" % f for f in broken]
|
||
line.info = "%d CUES ON TIME" % (len(cues) - len(off) - len(moved) - sum(1 for h in hits if not isinstance(h, list)))
|
||
return line
|
||
|
||
|
||
def light_hits(cfg, rc, look):
|
||
"""The hits under every cue, listened to on the Tracks Mac (once per saved set)."""
|
||
key = (look.get("set"), look.get("mtime"))
|
||
if key not in HITS:
|
||
cues = cue_times(rc, look["summary"])
|
||
hits = run_remote(cfg, rc["tracks_host"], HIT_SCRIPT % json.dumps({"cues": [[c[3], c[4], c[5]] for c in cues]}),
|
||
timeout=rc.get("remote_timeout", 90)) if cues else []
|
||
HITS.clear()
|
||
HITS[key] = hits
|
||
return HITS[key]
|
||
|
||
|
||
def moves(rc, summary, off):
|
||
"""[(beat, note, new beat)] that put each off cue (every flash/FX note on that 16th) onto its hit, on the
|
||
16th grid when the hit is within 10 ms of it."""
|
||
out = []
|
||
wanted = set(rc.get("light_hit_notes", range(30, 40)))
|
||
for c, dt in off:
|
||
beat, sixteenth = c[0], c[6]
|
||
new = beat + dt * 0.25 / sixteenth
|
||
grid = round(new * 4) / 4
|
||
if abs(grid - new) * 4 * sixteenth <= 0.010:
|
||
new = grid
|
||
for b, note in summary.get("cues") or []:
|
||
if b == beat and note in wanted:
|
||
out.append((beat, note, round(new, 4)))
|
||
return out
|
||
|
||
|
||
MOVE_SCRIPT = r"""
|
||
import json, socket, struct, time
|
||
P = json.loads(%r)
|
||
def pad(b):
|
||
return b + b"\0" * (4 - len(b) %% 4)
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
try:
|
||
s.bind(("127.0.0.1", 11001))
|
||
except OSError:
|
||
print(json.dumps({"error": "OSC PORT BUSY"})); raise SystemExit
|
||
s.settimeout(0.5)
|
||
done = []
|
||
for beat, note, new in P["moves"]:
|
||
s.sendto(pad(b"/live/statuspanel/move_note") + pad(b",sfif") + pad(P["track"].encode()) + struct.pack(">fif", beat, note, new),
|
||
("127.0.0.1", 11000))
|
||
end = time.time() + 3
|
||
while time.time() < end:
|
||
try:
|
||
b, _ = s.recvfrom(4096)
|
||
except socket.timeout:
|
||
continue
|
||
if b.startswith(b"/live/statuspanel/move_note"):
|
||
done.append([beat, note, struct.unpack(">i", b[-4:])[0]])
|
||
break
|
||
else:
|
||
print(json.dumps({"error": "NO MOVE COMMAND"})); raise SystemExit
|
||
print(json.dumps({"done": done}))
|
||
"""
|
||
|
||
|
||
SAVE_SCRIPT = r"""
|
||
import json, os, subprocess, time
|
||
P = json.loads(%r)
|
||
before = os.path.getmtime(P["set"])
|
||
r = subprocess.run(["/usr/bin/osascript", "-e", 'with timeout of 15 seconds\n'
|
||
'tell application "System Events" to click menu item "Save Live Set" of menu "File" of menu bar 1 of process "Live"\n'
|
||
'end timeout'], capture_output=True, text=True, timeout=30)
|
||
saved = False
|
||
for _ in range(40):
|
||
if os.path.getmtime(P["set"]) != before:
|
||
saved = True
|
||
break
|
||
time.sleep(0.25)
|
||
print(json.dumps({"saved": saved, "error": (r.stderr or "").strip()[-120:]}))
|
||
"""
|
||
|
||
|
||
def save_set(cfg, rc, path):
|
||
"""File > Save Live Set in Live on the Tracks Mac (System Events; osascript has Accessibility there,
|
||
2026-10-22), and True once the set file on disk is newer."""
|
||
r = run_remote(cfg, rc["tracks_host"], SAVE_SCRIPT % json.dumps({"set": path}), timeout=60)
|
||
if not r.get("saved"):
|
||
log.warning("Live did not save %s: %s", os.path.basename(path), r.get("error") or "the file did not change")
|
||
return bool(r.get("saved"))
|
||
|
||
|
||
def fix_light_timing(cfg, rc):
|
||
"""A hold: move every off cue onto its hit in the open set (AbletonOSC's status panel command)."""
|
||
light = LAST.get("light") or {}
|
||
if not light.get("off"):
|
||
return True
|
||
todo = moves(rc, light["summary"], light["off"])
|
||
try:
|
||
r = run_remote(cfg, rc["tracks_host"], MOVE_SCRIPT % json.dumps(
|
||
{"track": rc.get("lighting_track", "LIGHTING CUES"), "moves": todo}), timeout=60)
|
||
finally:
|
||
hand_back(cfg, rc["tracks_host"])
|
||
if r.get("error"):
|
||
return r["error"]
|
||
moved = sorted({b for b, _, n in r["done"] if n})
|
||
if moved:
|
||
MOVED.setdefault(light["key"], [])
|
||
MOVED[light["key"]] = sorted(set(MOVED[light["key"]]) | set(moved))
|
||
# Saved straight away, but only when the set open in Live is the one that was checked.
|
||
live, path = light.get("live_set"), light["key"][0]
|
||
if live and os.path.basename(live) == os.path.basename(path) and rc.get("light_autosave", True):
|
||
if save_set(cfg, rc, path):
|
||
log.info("FIX lighting cues: %s saved", os.path.basename(path))
|
||
else:
|
||
return "MOVED, SAVE BY HAND"
|
||
log.info("FIX lighting cues moved onto the hit in Live: %s%s", ", ".join(bar_of(b) for b in moved) or "none",
|
||
" (not found in the open set: %s)" % ", ".join(bar_of(b) for b, _, n in r["done"] if not n)
|
||
if any(not n for _, _, n in r["done"]) else "")
|
||
return True if moved else "NOT IN OPEN SET"
|
||
|
||
|
||
# --- 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()):
|
||
if b.get("copying"):
|
||
line.fault(WARN, "COPYING TO " + loc, "the copy to %s is still running" % loc)
|
||
elif not b.get("als"):
|
||
line.fault(ERR, "NOT SENT " + loc, "no set in %s" % b.get("folder"))
|
||
line.info = " ".join(sorted(look.get("bundles") or {})) or "NO LOCATIONS"
|
||
return line
|
||
|
||
|
||
def seconds_at(tempo, beat):
|
||
"""Seconds from the arrangement's start to `beat`, through the tempo automation (a step where two
|
||
points share a time, a ramp between two points)."""
|
||
pts = sorted(((t, v) for t, v in tempo.get("events") or [] if t >= 0), key=lambda p: p[0])
|
||
if not pts:
|
||
return beat * 60.0 / tempo["manual"]
|
||
first = [v for t, v in tempo.get("events") or [] if t < 0]
|
||
pts = [(0.0, first[-1] if first else pts[0][1])] + pts
|
||
secs = 0.0
|
||
for (t1, v1), (t2, v2) in zip(pts, pts[1:]):
|
||
if beat <= t1:
|
||
break
|
||
end = min(beat, t2)
|
||
if end > t1:
|
||
if abs(v2 - v1) < 1e-9:
|
||
secs += (end - t1) * 60.0 / v1
|
||
else:
|
||
v_end = v1 + (v2 - v1) * (end - t1) / (t2 - t1) # a ramp: integrate 60/bpm
|
||
secs += 60.0 * (end - t1) / (v_end - v1) * __import__("math").log(v_end / v1)
|
||
last_t, last_v = pts[-1]
|
||
if beat > last_t:
|
||
secs += (beat - last_t) * 60.0 / last_v
|
||
return secs
|
||
|
||
|
||
def ltc_clip_info(name):
|
||
"""(start seconds, fps) from an LTC file's name: "LTC_00000000_60mins_30fps_48000x24" -> (0, 30)."""
|
||
m = re.search(r"(\d{2})(\d{2})(\d{2})(\d{2})", name or "")
|
||
f = re.search(r"(\d+(?:\.\d+)?)\s*fps", name or "", re.I)
|
||
fps = float(f.group(1)) if f else 30.0
|
||
start = (int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) + int(m.group(4)) / fps) if m else 0.0
|
||
return start, fps
|
||
|
||
|
||
def timecode(seconds, fps):
|
||
frames = int(round(seconds * fps))
|
||
whole = int(round(fps))
|
||
return "%02d:%02d:%02d:%02d" % (frames // (whole * 3600), frames // (whole * 60) % 60, frames // whole % 60, frames % whole)
|
||
|
||
|
||
def song_ltc(rc, plan, look):
|
||
"""[{item, title, start beat, tracks: "HH:MM:SS:FF", pco: what PCO's LTC note says}] for every PCO song the set has.
|
||
A song starts at its locator ("2 - REJOICE AGAIN - Bb"), else at its group's first clip."""
|
||
summary = (look or {}).get("summary") or {}
|
||
clip = summary.get("ltc")
|
||
if not clip or not summary.get("tempo"):
|
||
return []
|
||
file_start, fps = ltc_clip_info(clip["name"])
|
||
clip_secs = seconds_at(summary["tempo"], clip["start"])
|
||
groups = song_groups(summary)
|
||
note = rc.get("ltc_note", "LTC")
|
||
out = []
|
||
for it in plan_songs(rc, plan):
|
||
loc = next((l for l in summary.get("locators") or [] if same(it["title"], l["name"])), None)
|
||
if loc is not None:
|
||
beat = loc["time"]
|
||
else:
|
||
gname = next((g for g in groups if same(it["title"], g)), None)
|
||
clips = [c for t in groups.get(gname, []) for c in t["clips"]]
|
||
if not clips:
|
||
continue
|
||
beat = min(c[0] for c in clips)
|
||
secs = seconds_at(summary["tempo"], beat) - clip_secs + clip.get("offset", 0) + file_start
|
||
out.append({"item": it, "title": it["title"], "beat": beat, "tracks": timecode(secs, fps),
|
||
"pco": (it["notes"].get(note) or "").strip()})
|
||
return out
|
||
|
||
|
||
def match_in_order(na_songs, campus_songs):
|
||
"""Pair a location's songs with the home location's by title, keeping the home order: a location song
|
||
whose place breaks that order (or that the home location does not play) is skipped, that song only.
|
||
Returns ([(location item, home song)], [skipped titles])."""
|
||
pairs, skipped, last = [], [], -1
|
||
for it in campus_songs:
|
||
idx = next((i for i, s in enumerate(na_songs) if i > last and same(it["title"], s["title"])), None)
|
||
if idx is None:
|
||
skipped.append(it["title"])
|
||
continue
|
||
pairs.append((it, na_songs[idx]))
|
||
last = idx
|
||
return pairs, skipped
|
||
|
||
|
||
def campus_ltc(rc, na_plan, na_songs, campus, plan):
|
||
"""One other location's plan against the home timecode: its songs in the home plan's order, each
|
||
with the timecode the home set gives that song (None while the set has none) and what its LTC
|
||
note says now."""
|
||
note = rc.get("ltc_note", "LTC")
|
||
pairs, skipped = match_in_order(plan_songs(rc, na_plan), plan_songs(rc, plan))
|
||
songs = []
|
||
for it, home in pairs:
|
||
tc = next((s["tracks"] for s in na_songs if same(home["title"], s["title"])), None)
|
||
songs.append({"item": it, "title": it["title"], "tracks": tc, "pco": (it["notes"].get(note) or "").strip()})
|
||
return {"name": campus.get("name") or campus.get("short") or str(campus.get("service_type")), "short": campus.get("short") or (campus.get("name") or "?")[:2].upper(),
|
||
"service_type": str(campus.get("service_type")), "plan_id": plan["id"], "dates": plan.get("dates", ""), "songs": songs, "skipped": skipped}
|
||
|
||
|
||
def eval_campus_ltc(rc, result, line):
|
||
"""The other location's notes must say the home timecode for every matched song."""
|
||
tag = result["short"]
|
||
for s in result["songs"]:
|
||
if s["tracks"] is None: # the home set has no timecode for it yet: the home line says so
|
||
continue
|
||
if not s["pco"]:
|
||
line.fault(ERR, "%s NO LTC %s" % (tag, short(s["title"], 12)), "%s: %s has no %s note; the tracks start it at %s" % (
|
||
result["name"], s["title"], rc.get("ltc_note", "LTC"), s["tracks"]))
|
||
elif s["pco"] != s["tracks"]:
|
||
line.fault(ERR, "%s LTC WRONG %s" % (tag, short(s["title"], 10)), "%s: %s says %s, the tracks start it at %s" % (
|
||
result["name"], s["title"], s["pco"], s["tracks"]))
|
||
if result["skipped"]:
|
||
line.notes.append("%s: not in this location's order, left alone: %s" % (result["name"], ", ".join(result["skipped"])))
|
||
return line
|
||
|
||
|
||
def eval_ltc(rc, plan, look, line):
|
||
"""PCO's LTC note for every song must be the timecode the tracks play at that song's start."""
|
||
for s in song_ltc(rc, plan, look):
|
||
if not s["pco"]:
|
||
line.fault(ERR, "NO LTC " + short(s["title"]), "%s: PCO has no %s note; the tracks start it at %s" % (
|
||
s["title"], rc.get("ltc_note", "LTC"), s["tracks"]))
|
||
elif s["pco"] != s["tracks"]:
|
||
line.fault(ERR, "LTC WRONG " + short(s["title"]), "%s: PCO says %s, the tracks start it at %s" % (
|
||
s["title"], s["pco"], s["tracks"]))
|
||
return line
|
||
|
||
|
||
COPY_SCRIPT = r'''
|
||
import json, os, shlex, subprocess
|
||
P = json.loads(%r)
|
||
def folder(loc):
|
||
return os.path.join(os.path.expanduser(P["projects"]), loc, P["folder"].format(date=P["date"], loc=loc))
|
||
src = folder(P["site"])
|
||
done = []
|
||
for loc in P["locations"]:
|
||
dst = folder(loc)
|
||
if os.path.exists(dst) or os.path.isdir(dst + ".copying") or not os.path.isdir(src):
|
||
continue
|
||
old = os.path.basename(src) + ".als"
|
||
new = os.path.basename(dst) + ".als"
|
||
# Copied under a temporary name and renamed at the end, so a half-copied project never looks sent.
|
||
cmd = ("mkdir -p %%s && rsync -a --exclude Backup/ %%s/ %%s/ && if [ -f %%s ]; then mv %%s %%s; fi && "
|
||
"[ ! -e %%s ] && mv %%s %%s") %% tuple(map(shlex.quote, (dst + ".copying", src, dst + ".copying",
|
||
dst + ".copying/" + old, dst + ".copying/" + old, dst + ".copying/" + new, dst, dst + ".copying", dst)))
|
||
subprocess.Popen(["/usr/bin/nohup", "/bin/sh", "-c", cmd], stdout=open("/tmp/status-panel-copy-%%s.log" %% loc, "w"),
|
||
stderr=subprocess.STDOUT, start_new_session=True)
|
||
done.append(loc)
|
||
print(json.dumps({"started": done, "source": os.path.isdir(src)}))
|
||
'''
|
||
|
||
|
||
# --- The lineup TV (ProdDash's Band Lineup) -------------------------------------------------
|
||
|
||
def name_score(shown, full):
|
||
"""How surely a name typed on the board is this PCO person: 3 the same, 2 "Alex Zinni" / "AlexG" / "DIEGO"
|
||
(first name, nickname, last initial), 0 not them."""
|
||
b = " ".join(re.sub(r"[^a-z ]", "", (shown or "").lower()).split())
|
||
f = " ".join(re.sub(r"[^a-z ]", "", (full or "").lower()).split())
|
||
if not b or not f:
|
||
return 0
|
||
if b == f:
|
||
return 3
|
||
first, last = f.split()[0], f.split()[-1]
|
||
words = b.split()
|
||
alike = lambda w: w == first or (len(w) >= 3 and (first.startswith(w) or w.startswith(first)))
|
||
if len(words) == 1:
|
||
w = words[0]
|
||
if w == first + last or w == first + last[0]:
|
||
return 2
|
||
return 1 if alike(w) else 0
|
||
return 2 if alike(words[0]) and words[-1] in (last, last[0]) else 0
|
||
|
||
|
||
def best_person(shown, people):
|
||
"""The PCO person a board name means, or None (a bare first name shared by two people means nobody)."""
|
||
scored = sorted(((name_score(shown, m["name"]), m) for m in people), key=lambda sm: -sm[0])
|
||
if not scored or scored[0][0] == 0:
|
||
return None
|
||
if len(scored) > 1 and scored[1][0] == scored[0][0] and scored[0][1]["name"] != scored[1][1]["name"]:
|
||
return None
|
||
return scored[0][1]
|
||
|
||
|
||
def eval_lineup(rc, plan, state):
|
||
line = Line()
|
||
st = (state or {}).get("state") or {}
|
||
shown = [s["person"]["name"] for s in st.get("slots") or [] if s.get("person") and s["person"].get("name")]
|
||
if str((st.get("plan") or {}).get("id")) != str(plan["id"]):
|
||
line.fault(ERR, "OLD PLAN", "the board shows %s, PCO's plan is %s" % ((st.get("plan") or {}).get("dates") or "nothing", plan["dates"]))
|
||
if st.get("lastError"):
|
||
line.fault(WARN, "SYNC ERROR", st["lastError"])
|
||
on_plan = [m for m in plan["team"] if not declined(m)]
|
||
gone = [m for m in plan["team"] if declined(m)]
|
||
teams = {t.lower() for t in rc.get("lineup_teams", ["Band", "Communication"])}
|
||
skip = {p.lower() for p in rc.get("lineup_skip_positions", [])}
|
||
seen = set()
|
||
for name in shown:
|
||
m = best_person(name, on_plan)
|
||
if m is not None:
|
||
seen.add(m["name"])
|
||
elif best_person(name, gone) is not None:
|
||
line.fault(ERR, "%s DECLINED" % short(name, 14), "%s is on the board but declined in PCO" % name)
|
||
else:
|
||
line.fault(ERR, "%s NOT ON PLAN" % short(name, 14), "%s is on the board but not on this plan" % name)
|
||
for m in on_plan:
|
||
if m["team"].lower() in teams and m["position"].lower() not in skip and m["name"] not in seen:
|
||
line.fault(ERR, "%s MISSING" % short(m["name"], 14), "%s (%s, %s) is on the plan but not on the board" % (
|
||
m["name"], m["team"], m["position"]))
|
||
line.info = "%d NAMES" % len(shown)
|
||
return line
|
||
|
||
|
||
# --- Resolume: the columns Sunday's slides call up -------------------------------------------
|
||
# A ProPresenter slide fires a macro (the .pro holds the macro's UUID); the macro sends RossTalk
|
||
# "CC page/row/column" to Companion; that Companion button (and the buttons it presses in turn)
|
||
# sends Resolume "/composition/columns/N/connect". Every column found that way must hold content.
|
||
|
||
MACRO_SCRIPT = r"""
|
||
import json, os, subprocess
|
||
P = json.loads(%r)
|
||
def show_dir():
|
||
try:
|
||
d = subprocess.run(["defaults", "read", "com.renewedvision.propresenter", "applicationShowDirectory"],
|
||
capture_output=True, text=True, timeout=5).stdout.strip()
|
||
if d:
|
||
return os.path.expanduser(d)
|
||
except Exception:
|
||
pass
|
||
return os.path.expanduser("~/Documents/ProPresenter")
|
||
def varint(b, i):
|
||
s = r = 0
|
||
while True:
|
||
x = b[i]; i += 1; r |= (x & 0x7f) << s
|
||
if not x & 0x80:
|
||
return r, i
|
||
s += 7
|
||
def fields(b):
|
||
out, i = [], 0
|
||
while i < len(b):
|
||
k, i = varint(b, i); num, wt = k >> 3, k & 7
|
||
if wt == 0: v, i = varint(b, i)
|
||
elif wt == 1: v, i = b[i:i + 8], i + 8
|
||
elif wt == 5: v, i = b[i:i + 4], i + 4
|
||
elif wt == 2:
|
||
n, i = varint(b, i); v = b[i:i + n]; i += n
|
||
else: raise ValueError(wt)
|
||
out.append((num, wt, v))
|
||
return out
|
||
def get(b, *path):
|
||
for p in path:
|
||
if not isinstance(b, bytes):
|
||
return None
|
||
f = [v for n, w, v in fields(b) if n == p]
|
||
if not f:
|
||
return None
|
||
b = f[0]
|
||
return b if isinstance(b, bytes) else None
|
||
macros, raw = {}, {}
|
||
for num, wt, v in fields(open(os.path.join(show_dir(), "Configuration", "Macros"), "rb").read()):
|
||
if num != 2 or wt != 2:
|
||
continue
|
||
try:
|
||
uuid = get(v, 1, 1).decode(); name = (get(v, 2) or b"").decode()
|
||
except Exception:
|
||
continue
|
||
cc = []
|
||
for n, w, act in fields(v):
|
||
comm = get(act, 32) if n == 4 and w == 2 else None
|
||
if comm is None:
|
||
continue
|
||
dev, fmt = (get(comm, 1, 2) or b"").decode(), (get(comm, 2) or b"").decode()
|
||
for n2, w2, cmd in fields(comm):
|
||
if n2 == 4 and w2 == 2:
|
||
cc.append([dev, fmt, (get(cmd, 2) or b"").decode()])
|
||
macros[uuid] = {"name": name, "cc": cc}
|
||
raw[uuid] = v
|
||
for u, v in raw.items(): # a macro that runs other macros
|
||
macros[u]["calls"] = [o for o in raw if o != u and o.encode() in v]
|
||
used = {}
|
||
for p in P["paths"]:
|
||
try:
|
||
b = open(p, "rb").read()
|
||
except OSError:
|
||
used[p] = None
|
||
continue
|
||
used[p] = [u for u in macros if u.encode() in b]
|
||
print(json.dumps({"macros": macros, "used": used}))
|
||
"""
|
||
|
||
COLUMN = re.compile(r"/composition/columns/(\d+)/connect")
|
||
LOCATION = re.compile(r"^\s*(\d+)/(\d+)/(\d+)\s*$")
|
||
PRESSES = ("button_pressrelease", "button_press", "button_release", "button_pressrelease_condition")
|
||
|
||
|
||
def companion_page(cfg, n):
|
||
"""One Companion page as Companion exports it (read-only)."""
|
||
comp = cfg.get("companion") or {}
|
||
url = "http://%s:%d/int/export/page/%d?includeSecrets=false" % (comp.get("host", "127.0.0.1"), comp.get("port", 8000), n)
|
||
with urllib.request.urlopen(url, timeout=6) as r:
|
||
raw = r.read()
|
||
try:
|
||
raw = __import__("gzip").decompress(raw)
|
||
except OSError:
|
||
pass
|
||
return json.loads(raw).get("page") or {}
|
||
|
||
|
||
def button_columns(get_page, location, seen=None):
|
||
"""The Resolume columns a Companion button connects, following the buttons it presses."""
|
||
seen = set() if seen is None else seen
|
||
m = LOCATION.match(location or "")
|
||
if not m or location in seen or len(seen) > 40:
|
||
return set()
|
||
seen.add(location)
|
||
page, row, col = (int(x) for x in m.groups())
|
||
ctl = (((get_page(page) or {}).get("controls") or {}).get(str(row)) or {}).get(str(col)) or {}
|
||
cols = set()
|
||
|
||
def walk(actions):
|
||
for a in actions or []:
|
||
opts = {k: (v.get("value") if isinstance(v, dict) else v) for k, v in (a.get("options") or {}).items()}
|
||
for v in opts.values():
|
||
for hit in COLUMN.findall(str(v)):
|
||
cols.add(int(hit))
|
||
if "olumn" in str(a.get("definitionId")) and str(opts.get("column", "")).isdigit():
|
||
cols.add(int(opts["column"]))
|
||
if a.get("connectionId") == "internal" and a.get("definitionId") in PRESSES:
|
||
cols.update(button_columns(get_page, str(opts.get("location", "")), seen))
|
||
for kids in (a.get("children") or {}).values():
|
||
walk(kids)
|
||
for step in (ctl.get("steps") or {}).values():
|
||
for actions in (step.get("action_sets") or {}).values():
|
||
walk(actions)
|
||
return cols
|
||
|
||
|
||
def macro_columns(macros, uuid, get_page, seen=None):
|
||
"""The columns one ProPresenter macro calls up (through the Companion buttons it presses)."""
|
||
seen = set() if seen is None else seen
|
||
if uuid in seen or uuid not in macros:
|
||
return set()
|
||
seen.add(uuid)
|
||
cols = set()
|
||
for dev, fmt, value in macros[uuid].get("cc") or []:
|
||
if fmt.strip().upper().startswith("CC"):
|
||
cols |= button_columns(get_page, value.replace(":", "/"))
|
||
for other in macros[uuid].get("calls") or []:
|
||
cols |= macro_columns(macros, other, get_page, seen)
|
||
return cols
|
||
|
||
|
||
def column_has_content(comp, n):
|
||
"""Column n (1-based) holds a clip on some layer that a column trigger plays."""
|
||
for layer in comp.get("layers") or []:
|
||
clips = layer.get("clips") or []
|
||
if n - 1 < len(clips):
|
||
c = clips[n - 1]
|
||
ignore = (c.get("ignorecolumntrigger") or {}).get("value") # "Layer Determined" / "On" / "Off"
|
||
if ((c.get("connected") or {}).get("value") or "Empty") != "Empty" and ignore not in ("On", True):
|
||
return True
|
||
return False
|
||
|
||
|
||
def eval_columns(rc, prop, found, get_page, comp):
|
||
"""Every Resolume column the plan's ProPresenter items call up holds content, on the right deck."""
|
||
line = Line()
|
||
deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None)
|
||
want = rc.get("resolume_deck", "")
|
||
if want and (deck is None or ((deck.get("name") or {}).get("value") or "") != want):
|
||
line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s: the columns would be another deck's" % (
|
||
((deck or {}).get("name") or {}).get("value"), want))
|
||
macros, used = found.get("macros") or {}, found.get("used") or {}
|
||
wanted = {} # column -> [items that call it up]
|
||
for it in prop.get("items") or []:
|
||
path = (it.get("pres") or {}).get("path")
|
||
for uuid in used.get(path) or []:
|
||
for col in macro_columns(macros, uuid, get_page):
|
||
wanted.setdefault(col, []).append(it["name"])
|
||
columns = comp.get("columns") or []
|
||
for col in sorted(wanted):
|
||
name = ((columns[col - 1].get("name") or {}).get("value") if col <= len(columns) else "") or ""
|
||
who = ", ".join(dict.fromkeys(wanted[col]))
|
||
if col > len(columns):
|
||
line.fault(ERR, "COL %d MISSING" % col, "%s call up column %d; Resolume has %d columns" % (who, col, len(columns)))
|
||
elif not column_has_content(comp, col):
|
||
line.fault(ERR, "COL %d EMPTY" % col, "column %d (%s) is empty in Resolume; %s call it up" % (col, name, who))
|
||
line.info = "%d COLUMNS OK" % len(wanted) if wanted else "NO COLUMNS USED"
|
||
return line
|
||
|
||
|
||
# --- Bundles, mics ---------------------------------------------------------------------------
|
||
|
||
BUNDLE_SCRIPT = r'''
|
||
import glob, json, os
|
||
P = json.loads(%r)
|
||
folder = os.path.expanduser(P["folder"])
|
||
files = glob.glob(os.path.join(folder, P["pattern"]))
|
||
parts = folder.split("/")
|
||
share = "/".join(parts[:3]) if folder.startswith("/Volumes/") and len(parts) > 2 else None # /Volumes/Location Assets
|
||
print(json.dumps({"files": sorted([[os.path.basename(f), os.path.getmtime(f)] for f in files], key=lambda x: -x[1])[:5],
|
||
"share": share, "mounted": os.path.ismount(share) if share else None}))
|
||
'''
|
||
|
||
|
||
def prop_bundle_folder(rc, plan):
|
||
"""Where this weekend's bundle goes: "{date}" is the weekend's folder (Location Assets/261025, the
|
||
user 2026-10-21: "it should be looking at the latest weekends folder in location assets")."""
|
||
return str(rc.get("prop_bundle_folder", "")).replace("{date}", datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"))
|
||
|
||
|
||
MOUNT_SCRIPT = r"""
|
||
import json, os, subprocess, time
|
||
P = json.loads(%r)
|
||
uid = os.getuid()
|
||
r = subprocess.run(["/bin/launchctl", "kickstart", "-k", "gui/%%d/%%s" %% (uid, P["label"])], capture_output=True, text=True)
|
||
for _ in range(60):
|
||
if os.path.ismount(P["share"]):
|
||
break
|
||
time.sleep(0.5)
|
||
print(json.dumps({"mounted": os.path.ismount(P["share"]), "no_agent": r.returncode != 0 and not os.path.ismount(P["share"])}))
|
||
"""
|
||
|
||
|
||
def eval_prop_bundle(rc, files, since, folder=None):
|
||
line = Line()
|
||
if isinstance(files, dict):
|
||
if files.get("share") and files.get("mounted") is False:
|
||
# 2026-10-22: Location Assets dropped off NA ProP; the bundle was there all along
|
||
line.fault(ERR, "SHARE NOT MOUNTED", "%s is not mounted on %s: hold PROP OUT to mount it again" % (
|
||
files["share"], rc.get("prop_bundle_host")))
|
||
return line
|
||
files = files.get("files") or []
|
||
if "{date}" in str(rc.get("prop_bundle_folder", "")):
|
||
# This weekend's own folder: any bundle in it is this weekend's.
|
||
if not files:
|
||
line.fault(ERR, "NOT IN " + os.path.basename(str(folder or "").rstrip("/")), "no %s in %s" % (rc.get("prop_bundle_pattern"), folder))
|
||
else:
|
||
line.info = "SENT " + datetime.fromtimestamp(files[0][1]).strftime("%a %H:%M").upper()
|
||
return line
|
||
fresh = [f for f in files if f[1] >= since]
|
||
if not fresh:
|
||
last = datetime.fromtimestamp(files[0][1]).strftime("%-m/%-d") if files else "NEVER"
|
||
line.fault(ERR, "NOT SENT (LAST %s)" % last, "no %s in %s saved since Monday" % (rc.get("prop_bundle_pattern"), rc.get("prop_bundle_folder")))
|
||
else:
|
||
line.info = "SENT " + datetime.fromtimestamp(fresh[0][1]).strftime("%a %H:%M").upper()
|
||
return line
|
||
|
||
|
||
def mics_needed(rc, plan):
|
||
"""What the mic table needs this weekend, from PCO: "4 VOX MC TIM"."""
|
||
vocal = {p.lower() for p in rc.get("vocal_positions", ["Vocalist", "[Leader]"])}
|
||
singers = {m["name"] for m in plan["team"] if not declined(m) and m["position"].lower() in vocal}
|
||
words = ["%d VOX" % len(singers)] if singers else []
|
||
for position, mic in sorted((rc.get("mic_positions") or {}).items()):
|
||
if any(m["position"].lower() == position.lower() and not declined(m) for m in plan["team"]):
|
||
words.append(mic)
|
||
return " ".join(words)
|
||
|
||
|
||
# --- The whole look ------------------------------------------------------------------------------
|
||
|
||
class Line:
|
||
"""One checklist line: its faults [(level, msg, detail)] and, when it is fine, what to show."""
|
||
|
||
def __init__(self):
|
||
self.faults, self.info, self.notes = [], "", []
|
||
|
||
def fault(self, level, msg, detail=""):
|
||
if all(m != msg for _, m, _ in self.faults):
|
||
self.faults.append((level, msg, detail))
|
||
|
||
def as_dict(self):
|
||
return {"faults": [[lv, m] for lv, m, _ in self.faults], "details": [d for _, _, d in self.faults if d] + list(self.notes),
|
||
"info": self.info}
|
||
|
||
|
||
def look(cfg, now=None, data_dir=None, sources=None):
|
||
"""Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}.
|
||
`sources` replaces the readers (tests)."""
|
||
rc = cfg.get("ready") or {}
|
||
now = now or time.time()
|
||
src = dict(pco=lambda: pco_plan(rc, now), pco_campus=lambda st: pco_plan(rc, now, st, team=False), prop=lambda plan: prop_look(rc, plan),
|
||
ableton=lambda plan: ableton_look(cfg, rc, plan),
|
||
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), live=lambda: live_look(cfg, rc),
|
||
hits=lambda al: light_hits(cfg, rc, al), services=lambda: upcoming_services(rc, now),
|
||
start=lambda svcs: start_look(cfg, rc, svcs), resi_now=lambda: resi_now(cfg, rc.get("resi_vox") or {}),
|
||
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 {})
|
||
lines = {}
|
||
|
||
def failed(names, err):
|
||
msg = str(err) if isinstance(err, ReadyError) else "CHECK FAILED"
|
||
if not isinstance(err, ReadyError):
|
||
log.warning("READY %s: %s", "/".join(names), err)
|
||
for n in names:
|
||
lines.setdefault(n, Line()).fault(WARN, msg, "%s: %s" % (msg, err))
|
||
|
||
try:
|
||
plan = src["pco"]()
|
||
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")), ("keys", rc.get("tracks_host")),
|
||
("tune", rc.get("tracks_host")), ("light_timing", 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")),
|
||
("start", start_on(rc)),
|
||
("led_cross", rc.get("resolume_url") and rc.get("prop_host"))) if on], e)
|
||
if plan is not None:
|
||
prop = None
|
||
if rc.get("prop_url"):
|
||
try:
|
||
prop = src["prop"](plan)
|
||
lines.update(eval_prop(rc, plan, prop))
|
||
except Exception as e: # noqa: BLE001
|
||
failed(["prop", "timecode", "backgrounds"] + (["led_cross"] if rc.get("resolume_url") and rc.get("prop_host") else []), e)
|
||
if rc.get("resolume_url") and rc.get("prop_host") and prop is not None:
|
||
try:
|
||
pages = {}
|
||
|
||
def get_page(n):
|
||
if n not in pages:
|
||
try:
|
||
pages[n] = src["companion"](n)
|
||
except (OSError, ValueError):
|
||
pages[n] = {}
|
||
return pages[n]
|
||
paths = sorted({(it.get("pres") or {}).get("path") for it in prop["items"]} - {None})
|
||
lines["led_cross"] = eval_columns(rc, prop, src["macros"](paths), get_page, src["resolume"]())
|
||
except Exception as e: # noqa: BLE001
|
||
failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE"))
|
||
if rc.get("tracks_host"):
|
||
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("light_timing_check", True):
|
||
try:
|
||
hits = src["hits"](al) if al.get("set") else []
|
||
lines["light_timing"] = eval_light_timing(rc, al, hits)
|
||
if al.get("set"):
|
||
cues = cue_times(rc, al["summary"])
|
||
LAST["light"] = {"key": (al["set"], al.get("mtime")), "summary": al["summary"], "live_set": al.get("live_set"),
|
||
"off": [(c, dt) for c, dt in off_cues(rc, cues, hits)
|
||
if c[0] not in set(MOVED.get((al["set"], al.get("mtime"))) or [])]}
|
||
except Exception as e: # noqa: BLE001 - a stem it cannot read must not hide the rest
|
||
failed(["light_timing"], e)
|
||
if rc.get("ltc_note", "LTC"):
|
||
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
|
||
na_songs = song_ltc(rc, plan, al)
|
||
LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in na_songs]
|
||
LAST["service_type"], LAST["plan_id"] = plan.get("service_type"), plan["id"]
|
||
others = []
|
||
for campus in (rc.get("ltc_campuses") or []) if na_songs else []: # nothing to give the others yet
|
||
if not campus.get("service_type"):
|
||
continue
|
||
try:
|
||
cplan = src["pco_campus"](campus["service_type"])
|
||
res = campus_ltc(rc, plan, na_songs, campus, cplan)
|
||
eval_campus_ltc(rc, res, lines["timecode"])
|
||
others.append(dict(res, songs=[dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in res["songs"]]))
|
||
except Exception as e: # noqa: BLE001 - one location's plan down must not hide the home one
|
||
tag = campus.get("short") or (campus.get("name") or "?")[:2].upper()
|
||
msg = str(e) if isinstance(e, ReadyError) else "PCO UNREACHABLE"
|
||
lines["timecode"].fault(WARN, "%s %s" % (tag, msg), "%s: %s" % (campus.get("name") or tag, e))
|
||
LAST["ltc_other"] = others
|
||
if rc.get("locations"):
|
||
lines["track_bundle"] = eval_track_bundle(rc, al)
|
||
except Exception as e: # noqa: BLE001
|
||
failed(["tracks", "mix_foh", "lights", "keys", "tune", "light_timing"] + (["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"]())
|
||
except Exception as e: # noqa: BLE001
|
||
failed(["lineup"], e if isinstance(e, ReadyError) else ReadyError("LINEUP UNREACHABLE"))
|
||
if start_on(rc):
|
||
try:
|
||
services = src["services"]()
|
||
lines["start"] = eval_start(rc, services, src["start"](services), now)
|
||
except Exception as e: # noqa: BLE001
|
||
failed(["start"], e)
|
||
if rc.get("resi_vox"):
|
||
try:
|
||
try:
|
||
lineup = src["lineup"]() if rc.get("lineup_url") else None
|
||
except (OSError, ValueError):
|
||
lineup = None # PCO's team positions are used instead
|
||
now_vox, got = src["resi_now"]()
|
||
lines["resi_vox"] = eval_resi_vox(rc, plan, lineup, now_vox, got)[0]
|
||
except Exception as e: # noqa: BLE001
|
||
failed(["resi_vox"], e)
|
||
mics = Line()
|
||
mics.info = mics_needed(rc, plan)
|
||
lines["mics"] = mics
|
||
if rc.get("prop_bundle_host"):
|
||
try:
|
||
lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](plan), week_start(now, rc.get("reset_day", "mon")),
|
||
prop_bundle_folder(rc, plan))
|
||
except Exception as e: # noqa: BLE001
|
||
failed(["prop_bundle"], e)
|
||
out = {"time": now, "items": {k: v.as_dict() for k, v in lines.items()}}
|
||
if plan is not None:
|
||
out["plan"] = {"id": plan["id"], "title": plan["title"], "dates": plan["dates"], "time": plan["time"]}
|
||
return out
|
||
|
||
|
||
class Poller(threading.Thread):
|
||
"""Looks every "interval_seconds" (or at once after a hold), never while `is_live()` - a
|
||
recording HyperDeck means a service, and the Tracks and ProPresenter Macs are busy."""
|
||
|
||
def __init__(self, cfg, snap, stop, data_dir=None, is_live=lambda: False):
|
||
super().__init__(name="ready", daemon=True)
|
||
self.cfg, self.snap, self.stop, self.data_dir, self.is_live = cfg, snap, stop, data_dir, is_live
|
||
self.interval = (cfg.get("ready") or {}).get("interval_seconds", 120)
|
||
IS_LIVE[0] = is_live
|
||
|
||
def run(self):
|
||
forced = True
|
||
while not self.stop.is_set():
|
||
if forced or not self.is_live():
|
||
try:
|
||
result = look(self.cfg, data_dir=self.data_dir)
|
||
self.snap.ready = result
|
||
for k in ("time", "items", "plan"):
|
||
LAST.pop(k, None)
|
||
LAST.update(result)
|
||
except Exception: # noqa: BLE001 - the panel carries on without the READY page
|
||
log.exception("READY look failed")
|
||
WAKE.clear()
|
||
forced = False
|
||
deadline = time.time() + self.interval
|
||
while not self.stop.is_set() and time.time() < deadline:
|
||
if WAKE.wait(1.0):
|
||
forced = True
|
||
break
|
||
|
||
|
||
# --- START: the week's Ableton sets (ableton_start.py, run on the Tracks Mac) ----------------------------
|
||
# The services come from PCO: this location's weekend service type, and every special one beside it (same
|
||
# parent folder in PCO: "Special Services" holds First / Together Tuesday, "Morning Worship", "Forge &
|
||
# Flourish"...), skipping youth, kids and classes. A plan counts when it has songs with a key.
|
||
|
||
START_SOURCE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ableton_start.py")
|
||
START_TYPES = r"services|events|worship|forge|flourish"
|
||
START_SKIP = (r"youth|yth|kids|young adult|growth|baptism|guest|security|caf|preschool|nursery|toddler|elementary|"
|
||
r"academy|homeschool|audition|connect")
|
||
SERVICES = {} # the services START builds, kept a quarter of an hour (not asked of PCO every look)
|
||
IS_LIVE = [lambda: False] # the Poller's "a service is on": the hold copies gigabytes, never during one
|
||
|
||
START_SURVEY = r'''
|
||
import json, os
|
||
P = json.loads(%r)
|
||
out = {}
|
||
for s in P["services"]:
|
||
d = os.path.join(os.path.expanduser(P["projects"]), s["folder"], s["name"])
|
||
out[s["name"]] = {"exists": os.path.isdir(d) and any(n.endswith(".als") for n in os.listdir(d)),
|
||
"building": os.path.isdir(d + ".building")}
|
||
try:
|
||
st = json.load(open(os.path.expanduser("~/Library/Caches/status-panel-start.json")))
|
||
except Exception:
|
||
st = None
|
||
print(json.dumps({"survey": out, "status": st}))
|
||
'''
|
||
|
||
|
||
def start_on(rc):
|
||
"""START is where the sets are made: the location that sends its tracks to the others (or one set to)."""
|
||
on = rc.get("start")
|
||
return bool(rc.get("tracks_host") and rc.get("projects") and (on if on is not None else rc.get("locations")))
|
||
|
||
|
||
def start_window(rc, now):
|
||
"""Today up to the Thursday after this week's Sunday: on Monday that is Sunday and a First Tuesday."""
|
||
today = datetime.fromtimestamp(now).replace(hour=0, minute=0, second=0, microsecond=0)
|
||
return time.mktime(today.timetuple()), week_start(now, rc.get("reset_day", "mon")) + 10 * 86400
|
||
|
||
|
||
def plan_service(auth, st, plan_id, rc):
|
||
"""(songs, salvation) of a plan: the band's songs with their PCO key and tempo, in order; Salvation is the
|
||
song after the Salvation moment (a TAG) if there is one, else the last song (as the sets do it)."""
|
||
items, inc = pco_get(auth, "/service_types/%s/plans/%s/items?include=arrangement&per_page=100" % (st, plan_id))
|
||
bpm = {i["id"]: (i.get("attributes") or {}).get("bpm") for i in inc if i.get("type") == "Arrangement"}
|
||
skip = {norm(x) for x in rc.get("skip_songs", [])}
|
||
songs, sal, past = [], None, False
|
||
for it in items:
|
||
a = it.get("attributes") or {}
|
||
title = (a.get("title") or "").strip()
|
||
if re.search(r"salvation", title, re.I):
|
||
past = True
|
||
continue
|
||
if a.get("item_type") != "song" or a.get("service_position") not in ("during", "") or not a.get("key_name"):
|
||
continue
|
||
if norm(title) in skip:
|
||
continue
|
||
ref = ((it.get("relationships") or {}).get("arrangement") or {}).get("data") or {}
|
||
song = {"title": title, "key": a["key_name"], "bpm": bpm.get(ref.get("id"))}
|
||
if past or re.search(r"\bTAG\b", title, re.I):
|
||
sal = sal or song
|
||
continue
|
||
if not any(same(song["title"], x["title"]) and same_key(song["key"], x["key"]) for x in songs):
|
||
songs.append(song)
|
||
return songs, sal or (songs[-1] if songs else None)
|
||
|
||
|
||
def service_entry(rc, weekend, type_name, plan, t, songs, sal):
|
||
"""Where a service's set goes: "NA/261101 NA Project", "FIRST TUESDAY/261103 NA First Tuesday Project"."""
|
||
d = datetime.fromtimestamp(t)
|
||
date, loc = d.strftime("%y%m%d"), rc.get("site_folder", "")
|
||
fmt = rc.get("folder_format", "{date} {loc} Project")
|
||
a = plan.get("attributes") or {}
|
||
title = " ".join(re.sub(r"[/:\\]", "-", (a.get("title") or type_name.split("|")[-1]).strip()).split())
|
||
if weekend:
|
||
folder, name = loc, fmt.format(date=date, loc=loc)
|
||
else:
|
||
tues = re.search(r"tuesday", title + " " + type_name, re.I)
|
||
folder = rc.get("start_tuesday_folder", "FIRST TUESDAY") if tues else loc
|
||
name = fmt.format(date=date, loc="%s %s" % (loc, title))
|
||
label = "%s %d/%d" % (d.strftime("%a").upper(), d.month, d.day)
|
||
return {"date": date, "folder": folder, "name": name, "label": label, "title": title or type_name,
|
||
"songs": songs, "salvation": sal, "service_type": plan.get("_st"), "plan_id": plan.get("id")}
|
||
|
||
|
||
def upcoming_services(rc, now, fresh=False):
|
||
"""The services START builds sets for, soonest first."""
|
||
lo, hi = start_window(rc, now)
|
||
key = (lo, int(now // 900))
|
||
if not fresh and SERVICES.get("key") == key:
|
||
return SERVICES["list"]
|
||
auth, home = pco_login(rc)
|
||
try:
|
||
types, _ = pco_get(auth, "/service_types?per_page=100", pages=3)
|
||
except OSError as e:
|
||
raise ReadyError("PCO UNREACHABLE") from e
|
||
by_id = {x["id"]: x for x in types}
|
||
parent_of = lambda x: ((((x or {}).get("relationships") or {}).get("parent") or {}).get("data") or {}).get("id")
|
||
name_of = lambda x: ((x or {}).get("attributes") or {}).get("name") or ""
|
||
want = re.compile(rc.get("start_types", START_TYPES), re.I)
|
||
skip = re.compile(rc.get("start_skip_types", START_SKIP), re.I)
|
||
chosen = [home] + [x["id"] for x in types if x["id"] != home and parent_of(x) and parent_of(x) == parent_of(by_id.get(home))
|
||
and want.search(name_of(x)) and not skip.search(name_of(x))]
|
||
chosen += [str(x) for x in rc.get("start_service_types", []) if str(x) not in chosen]
|
||
out = []
|
||
for st in chosen:
|
||
try:
|
||
plans, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1)
|
||
except OSError as e:
|
||
raise ReadyError("PCO UNREACHABLE") from e
|
||
for p in plans:
|
||
t = parse_iso((p.get("attributes") or {}).get("sort_date"))
|
||
if t is None or not lo <= t < hi:
|
||
continue
|
||
songs, sal = plan_service(auth, st, p["id"], rc)
|
||
if songs:
|
||
out.append(service_entry(rc, st == home, name_of(by_id.get(st)), dict(p, _st=st), t, songs, sal))
|
||
out.sort(key=lambda x: (x["date"], x["name"]))
|
||
SERVICES.update(key=key, list=out)
|
||
return out
|
||
|
||
|
||
def start_params(rc, services, mode):
|
||
projects = rc.get("projects", "")
|
||
return {"mode": mode, "projects": projects, "site_folder": rc.get("site_folder", ""),
|
||
"folder_format": rc.get("folder_format", "{date} {loc} Project"), "finished_folders": rc.get("finished_folders", []),
|
||
"pads_folder": rc.get("pads_folder") or os.path.join(os.path.dirname(projects.rstrip("/")), "PADS"),
|
||
"tune_library": rc.get("tune_library", ""), "routing": rc.get("routing") or {},
|
||
"keep_groups": rc.get("start_keep_groups") or rc.get("ears_skip_groups") or ["MIDI GROUP", "RESI TRACKS"],
|
||
"pad_track": rc.get("pad_track", "AMBIENT PADS"), "tune_track": rc.get("tune_track", "WAVES TUNE"),
|
||
"lighting_track": rc.get("lighting_track", "LIGHTING CUES"), "sections_track": rc.get("sections_track", "SECTIONS"),
|
||
"guide_track": rc.get("guide_track", "+GUIDE"), "loopguide_track": rc.get("loopguide_track", "+LOOPGUIDE"),
|
||
"loop_cue_track": rc.get("loop_cue_track", "Loop Cue"), "tempo_track": rc.get("tempo_track", "TEMPO"),
|
||
"send_a": 1.0, "save_songs": rc.get("start_save_songs", True), "services": services}
|
||
|
||
|
||
def start_script(params):
|
||
"""ableton_start.py with its job; it keeps its own source, to start itself detached on the Tracks Mac."""
|
||
with open(START_SOURCE) as f:
|
||
src = f.read()
|
||
return "SOURCE = %r\n%s\nmain(json.loads(%r))\n" % (src, src, json.dumps(params))
|
||
|
||
|
||
def start_look(cfg, rc, services):
|
||
return run_remote(cfg, rc["tracks_host"], START_SURVEY % json.dumps({"projects": rc.get("projects", ""), "services": [
|
||
{"folder": x["folder"], "name": x["name"]} for x in services]}), timeout=30)
|
||
|
||
|
||
def eval_start(rc, services, look, now):
|
||
"""START: every service this week has its set (built, or being built); what the last build left to do."""
|
||
line = Line()
|
||
st = look.get("status") or {}
|
||
survey = look.get("survey") or {}
|
||
running = st.get("state") == "running" and now - (st.get("time") or 0) < 1800
|
||
if running:
|
||
line.fault(WARN, (st.get("step") or "BUILDING")[:24], "START is building: %s" % st.get("step"))
|
||
elif st.get("state") == "failed" and now - (st.get("time") or 0) < 7 * 86400:
|
||
line.fault(ERR, "START FAILED", "the last build stopped: %s" % st.get("step"))
|
||
for svc in services:
|
||
got = survey.get(svc["name"]) or {}
|
||
if not got.get("exists") and not running:
|
||
line.fault(ERR, "NO SET %s" % svc["label"], "%s has no Ableton set yet (%s/%s): hold START to build it" % (
|
||
svc["title"] or svc["label"], svc["folder"], svc["name"]))
|
||
if st.get("state") == "done" and now - (st.get("time") or 0) < 86400:
|
||
names = {x["name"] for x in services if (survey.get(x["name"]) or {}).get("exists")}
|
||
for res in st.get("results") or []:
|
||
if res.get("name") not in names:
|
||
continue
|
||
for note in res.get("notes") or []:
|
||
m = re.match(r"TRANSPOSE (.+?) \S+>\S+", note)
|
||
n = re.match(r"NEW SONG (.+?):", note)
|
||
if m:
|
||
line.fault(WARN, "CONSOLIDATE " + short(m.group(1), 12), "%s: %s" % (res["name"], note))
|
||
elif n:
|
||
line.fault(WARN, "NEW SONG " + short(n.group(1), 12), "%s: %s" % (res["name"], note))
|
||
else:
|
||
line.notes.append("%s: %s" % (res["name"], note))
|
||
line.info = " ".join(x["label"] for x in services) or "NO SERVICES"
|
||
return line
|
||
|
||
|
||
# --- RESI VOX: the lead singer's mic to the locations ----------------------------------------------------
|
||
# The Resi song goes from NA to every other location with one vocal: the Resi AJA's "Lead Vocal" channel, which
|
||
# Companion's page 13 VOX1-4 AJA buttons subscribe to the wireless receiver's channel 1-4 (BOH-AD4Q-A). The lineup
|
||
# screen (ProdDash) says which VOX each singer is on; PCO's song description says who leads ("Genesis lead").
|
||
|
||
DANTE_ARC = 4440
|
||
|
||
|
||
def resi_song(plan):
|
||
"""The song that goes out over Resi: the one PCO marks RESI (description), else the last before the message."""
|
||
songs, last = [], None
|
||
for it in plan["items"]:
|
||
if it["type"] == "header" and "message" in it["title"].lower():
|
||
break
|
||
if it["type"] == "song" and it["position"] in ("during", ""):
|
||
songs.append(it)
|
||
last = it
|
||
marked = [it for it in plan["items"] if it["type"] == "song" and re.search(r"\bRESI\b", it.get("description") or "", re.I)]
|
||
return marked[-1] if marked else last
|
||
|
||
|
||
def song_leads(item):
|
||
""""Genesis lead" -> ["Genesis"]; "Mike and DJ lead" -> ["Mike", "DJ"]; nobody named -> []."""
|
||
for line in (item.get("description") or "").splitlines():
|
||
m = re.match(r"^\W*(.+?)\s+leads?\b", line.strip(), re.I)
|
||
if m:
|
||
return [n.strip() for n in re.split(r"\s*(?:,|&|\+|/|\band\b)\s*", m.group(1), flags=re.I) if n.strip()]
|
||
return []
|
||
|
||
|
||
def vox_of(label):
|
||
m = re.search(r"\bVOX\s*(\d+)", label or "", re.I)
|
||
return int(m.group(1)) if m else None
|
||
|
||
|
||
def lead_vox(name, lineup, team):
|
||
"""(VOX number, full name, where it came from) for a first name ("Genesis"): the lineup screen first (it is
|
||
what the band sees), else the plan's team positions; None when nobody or more than one person fits."""
|
||
def fits(full):
|
||
return bool(full) and norm(full).split()[:len(norm(name).split())] == norm(name).split()
|
||
slots = [s for s in ((lineup or {}).get("state") or {}).get("slots") or [] if s.get("person") and fits(s["person"].get("name"))]
|
||
voxed = [(vox_of(s.get("label")), s["person"]["name"]) for s in slots if vox_of(s.get("label"))]
|
||
if len({v for v, _ in voxed}) == 1:
|
||
return voxed[0][0], voxed[0][1], "lineup"
|
||
if voxed:
|
||
return None, ", ".join(n for _, n in voxed), "lineup"
|
||
people = [(vox_of(m.get("position")), m["name"]) for m in team or [] if fits(m.get("name")) and vox_of(m.get("position"))
|
||
and m.get("status") != "D"]
|
||
if len({v for v, _ in people}) == 1:
|
||
return people[0][0], people[0][1], "PCO"
|
||
return None, ", ".join(n for _, n in people), "PCO"
|
||
|
||
|
||
def dante_rx(ip, channel, timeout=2.0):
|
||
"""What a Dante device's receive channel is subscribed to, asked the way Dante Controller asks (read only):
|
||
{"name", "tx_channel", "tx_device", "status"}, or None if it does not answer."""
|
||
page = (channel - 1) // 16
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
s.settimeout(timeout)
|
||
try:
|
||
seq = int(time.time() * 1000) & 0xffff
|
||
s.sendto(bytes.fromhex("27ff0010%04x3000" % seq + "0000000100%x10000" % page), (ip, DANTE_ARC))
|
||
d, _ = s.recvfrom(4096)
|
||
except OSError:
|
||
return None
|
||
finally:
|
||
s.close()
|
||
|
||
def cstr(o):
|
||
if not o or o >= len(d):
|
||
return ""
|
||
e = d.find(b"\0", o)
|
||
return d[o:e if e >= 0 else len(d)].decode("utf-8", "replace")
|
||
for i in range(16):
|
||
rec = d[12 + i * 20:32 + i * 20]
|
||
if len(rec) < 20:
|
||
break
|
||
num, _, _, tx_chan, tx_dev, rx_name, status, _, _, _ = struct.unpack(">10H", rec)
|
||
if num == channel:
|
||
return {"name": cstr(rx_name), "tx_channel": cstr(tx_chan), "tx_device": cstr(tx_dev), "status": status}
|
||
return None
|
||
|
||
|
||
def companion_var(cfg, conn, name):
|
||
comp = cfg.get("companion") or {}
|
||
url = "http://%s:%s/api/variable/%s/%s/value" % (comp.get("host", "127.0.0.1"), comp.get("port", 8000),
|
||
urllib.parse.quote(conn), urllib.parse.quote(name))
|
||
with urllib.request.urlopen(url, timeout=5) as r:
|
||
return r.read().decode("utf-8", "replace").strip()
|
||
|
||
|
||
def resi_now(cfg, rv):
|
||
"""The VOX number the Resi AJA's lead channel receives now (None: something else), and what it is."""
|
||
ip = companion_var(cfg, rv.get("dante_conn", "audinate-dantecontroller"), rv["aja"] + "_ip")
|
||
got = dante_rx(ip, rv.get("channel", 21)) if ip and ip != "Not found" else None
|
||
if got is None:
|
||
raise ReadyError("AJA NO ANSWER")
|
||
if got["tx_device"] != rv.get("mics", "BOH-AD4Q-A"):
|
||
return None, got
|
||
n = vox_of(got["tx_channel"])
|
||
if n is None:
|
||
try:
|
||
names = json.loads(companion_var(cfg, rv.get("dante_conn", "audinate-dantecontroller"), rv.get("mics", "BOH-AD4Q-A") + "_tx_names"))
|
||
n = names.index(got["tx_channel"]) + 1
|
||
except (ValueError, OSError):
|
||
n = None
|
||
return n, got
|
||
|
||
|
||
def eval_resi_vox(rc, plan, lineup, now_vox, now_got):
|
||
"""(Line, the VOX the lead is on or None): what the locations hear on the Resi song."""
|
||
line, rv = Line(), rc.get("resi_vox") or {}
|
||
buttons = {int(k) for k in (rv.get("buttons") or {})}
|
||
song = resi_song(plan)
|
||
if song is None:
|
||
line.fault(WARN, "NO RESI SONG", "PCO has no song before the message")
|
||
return line, None
|
||
leads = song_leads(song)
|
||
where = "%s (%s)" % (song["title"], "marked RESI" if re.search(r"\bRESI\b", song.get("description") or "", re.I)
|
||
else "the last song before the message")
|
||
if not leads:
|
||
line.fault(WARN, "NO LEAD " + short(song["title"], 12), "%s: PCO's description names no lead (\"Genesis lead\")" % where)
|
||
return line, None
|
||
found = [(n,) + lead_vox(n, lineup, plan.get("team")) for n in leads]
|
||
voxes = {v for _, v, _, _ in found if v}
|
||
for n, v, who, src in found:
|
||
if v is None:
|
||
line.fault(WARN, "%s NOT ON LINEUP" % short(n, 10), "%s leads %s, but %s" % (n, where, (
|
||
"more than one person fits: " + who) if who else "nobody of that name has a VOX on the lineup or in PCO"))
|
||
want = None
|
||
if len(voxes) == 1 and all(v for _, v, _, _ in found):
|
||
want = voxes.pop()
|
||
elif len(voxes) > 1:
|
||
line.fault(WARN, "%d LEADS: %s" % (len(found), " ".join(short(n, 6) for n, _, _, _ in found)),
|
||
"%s has %s leading (VOX %s): pick the one Resi gets by hand" % (
|
||
where, " and ".join(n for n, _, _, _ in found), ", ".join(str(v) for _, v, _, _ in found)))
|
||
audio = (song.get("notes") or {}).get(rv.get("note", "Audio"), "")
|
||
said = re.search(r"\bV(?:OX)?\s*(\d)\b", audio or "", re.I)
|
||
if want and said and int(said.group(1)) != want:
|
||
line.fault(WARN, "PCO SAYS V%s, LEAD V%d" % (said.group(1), want), "%s: the Audio note says %s, but %s is on VOX %d "
|
||
"on the lineup: check which is right (nothing is switched while they differ)" % (where, audio, found[0][2], want))
|
||
want = None
|
||
if want and buttons and want not in buttons:
|
||
line.fault(ERR, "LEAD ON VOX %d: NO BUTTON" % want, "%s is on VOX %d; page 13 only has VOX %s AJA" % (
|
||
found[0][2], want, ", ".join(str(b) for b in sorted(buttons))))
|
||
want = None
|
||
have = "VOX %d" % now_vox if now_vox else "%s @ %s" % (now_got.get("tx_channel") or "nothing", now_got.get("tx_device") or "-")
|
||
if want and now_vox != want:
|
||
line.fault(ERR, "RESI %s NOT VOX %d" % (("V%d" % now_vox) if now_vox else "MIC WRONG", want),
|
||
"%s: %s leads (VOX %d on the %s); the Resi AJA's lead channel gets %s" % (
|
||
where, found[0][2], want, found[0][3], have))
|
||
who = next(((w or n).split()[0] for n, v, w, _ in found if v and v == now_vox), "")
|
||
line.info = ("VOX %d %s" % (now_vox, short(who, 10))).strip() if now_vox else have[:16]
|
||
line.notes.append("%s: lead %s; AJA %s receives %s" % (where, ", ".join("%s = VOX %s (%s)" % (w or n, v, s) for n, v, w, s in found),
|
||
now_got.get("name"), have))
|
||
return line, want
|
||
|
||
|
||
def fix_resi_vox(cfg, rc, sleep=time.sleep):
|
||
"""Press the lead's VOX button on Companion (page 13), then read the AJA back."""
|
||
import status_panel
|
||
plan = pco_plan(rc, time.time())
|
||
rv = rc.get("resi_vox") or {}
|
||
lineup = http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6) if rc.get("lineup_url") else None
|
||
now_vox, got = resi_now(cfg, rv)
|
||
line, want = eval_resi_vox(rc, plan, lineup, now_vox, got)
|
||
if not want:
|
||
return (line.faults[0][1] if line.faults else "NOTHING TO DO")[:20]
|
||
if now_vox == want:
|
||
return True
|
||
page, row, col = rv["buttons"][str(want)]
|
||
status = status_panel.make_companion(cfg).press(page, row, col)
|
||
log.info("FIX RESI VOX: VOX %s -> VOX %d (Companion %d/%d/%d: %s)", now_vox, want, page, row, col, status)
|
||
for _ in range(10):
|
||
sleep(1)
|
||
if resi_now(cfg, rv)[0] == want:
|
||
return True
|
||
return "STILL VOX %s" % (resi_now(cfg, rv)[0] or "?")
|
||
|
||
|
||
# --- Fixes (a hold on a READY key: fix kind "ready") ------------------------------------------------
|
||
|
||
def write_ltc(rc, songs, service_type, plan_id):
|
||
"""Write the tracks' timecode into PCO's LTC note of every song where it is missing or different."""
|
||
todo = [s for s in songs if s["tracks"] and s["pco"] != s["tracks"]]
|
||
if not todo:
|
||
return True
|
||
if not (service_type and plan_id):
|
||
return "NO PLAN YET"
|
||
auth, _ = pco_login(rc)
|
||
note = rc.get("ltc_note", "LTC")
|
||
base = "%s/service_types/%s/plans/%s/items" % (PCO, service_type, plan_id)
|
||
headers = {"Authorization": auth, "Content-Type": "application/json"}
|
||
category = None
|
||
for s in todo:
|
||
note_id = s["item"]["note_ids"].get(note)
|
||
if note_id:
|
||
body = {"data": {"type": "ItemNote", "id": note_id, "attributes": {"content": s["tracks"]}}}
|
||
http_json("%s/%s/item_notes/%s" % (base, s["item"]["id"], note_id), headers, "PATCH",
|
||
json.dumps(body).encode(), timeout=15)
|
||
else:
|
||
if category is None:
|
||
cats, _ = pco_get(auth, "/service_types/%s/item_note_categories" % service_type, pages=3)
|
||
category = next((c["id"] for c in cats if ((c.get("attributes") or {}).get("name") or "").lower() == note.lower()), "")
|
||
if not category:
|
||
return "NO %s IN PCO" % note.upper()
|
||
body = {"data": {"type": "ItemNote", "attributes": {"content": s["tracks"], "item_note_category_id": category}}}
|
||
http_json("%s/%s/item_notes" % (base, s["item"]["id"]), headers, "POST", json.dumps(body).encode(), timeout=15)
|
||
log.info("FIX PCO %s note for %s: %s -> %s", note, s["title"], s["pco"] or "(none)", s["tracks"])
|
||
return True
|
||
|
||
|
||
def run_action(cfg, action, chk=None, sleep=time.sleep):
|
||
"""One "ready" fix. True when it worked; a string says what to do instead."""
|
||
rc = cfg.get("ready") or {}
|
||
try:
|
||
if action == "recheck":
|
||
return True
|
||
if action == "lineup_sync":
|
||
base = rc.get("lineup_url", "").rstrip("/")
|
||
before = ((http_json(base + "/state", timeout=6) or {}).get("state") or {}).get("lastFetched")
|
||
http_json(base + "/sync", method="POST", data=b"{}", headers={"Content-Type": "application/json"}, timeout=10)
|
||
for _ in range(30):
|
||
sleep(1)
|
||
st = (http_json(base + "/state", timeout=6) or {}).get("state") or {}
|
||
if st.get("lastFetched") != before:
|
||
log.info("FIX lineup synced from PCO%s", (": " + st["lastError"]) if st.get("lastError") else "")
|
||
return "SYNC ERROR" if st.get("lastError") else True
|
||
return "SYNC SLOW"
|
||
if action == "led_cross":
|
||
base = rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition"
|
||
comp = http_json(base, timeout=6) or {}
|
||
want = rc.get("resolume_deck", "")
|
||
for i, d in enumerate(comp.get("decks") or []):
|
||
if want and ((d.get("name") or {}).get("value") == want) and not (d.get("selected") or {}).get("value"):
|
||
http_json("%s/decks/%d/select" % (base, i + 1), method="POST", timeout=6)
|
||
log.info("FIX Resolume deck %s selected", want)
|
||
sleep(1)
|
||
comp = http_json(base, timeout=6) or {}
|
||
return True # an empty column is filled by hand in Resolume
|
||
if action == "ltc_to_pco":
|
||
result = write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
|
||
for other in LAST.get("ltc_other") or []:
|
||
r = write_ltc(rc, other["songs"], other["service_type"], other["plan_id"])
|
||
if r is not True and result is True:
|
||
result = "%s: %s" % (other["short"], r)
|
||
return result
|
||
if action == "copy_tracks":
|
||
plan = LAST.get("plan")
|
||
if not plan:
|
||
return "NO PLAN YET"
|
||
r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60)
|
||
if not r.get("source"):
|
||
return "NO SET TO COPY"
|
||
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
|
||
return True
|
||
if action == "mount_share":
|
||
# The computer's keep-mounted agent (remote-access/mac-keep-mounted.sh) mounts it again, now.
|
||
label = "org.waterschurch.keep-mounted." + rc.get("prop_bundle_share_agent", "location-assets")
|
||
share = "/".join(str(rc.get("prop_bundle_folder", "")).split("/")[:3])
|
||
r = run_remote(cfg, rc["prop_bundle_host"], MOUNT_SCRIPT % json.dumps({"label": label, "share": share}), timeout=60)
|
||
if r.get("mounted"):
|
||
log.info("FIX %s mounted on %s", share, rc["prop_bundle_host"])
|
||
return True
|
||
return "NO AGENT" if r.get("no_agent") else "STILL NOT MOUNTED"
|
||
if action == "light_timing":
|
||
return fix_light_timing(cfg, rc)
|
||
if action == "resi_vox":
|
||
return fix_resi_vox(cfg, rc, sleep)
|
||
if action == "start_week":
|
||
if IS_LIVE[0]():
|
||
return "SERVICE ON"
|
||
services = upcoming_services(rc, time.time(), fresh=True)
|
||
if not services:
|
||
return "NO SERVICES IN PCO"
|
||
r = run_remote(cfg, rc["tracks_host"], start_script(start_params(rc, services, "run")), timeout=60)
|
||
if r.get("busy"):
|
||
return "ALREADY BUILDING"
|
||
log.info("FIX START: building the sets for %s", ", ".join("%s (%s)" % (x["label"], x["name"]) for x in services))
|
||
return True if r.get("started") else "DID NOT START"
|
||
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:
|
||
return str(e)
|
||
except (OSError, ValueError) as e:
|
||
log.warning("FIX ready %s failed: %s", action, e)
|
||
return "PCO WRITE FAILED" if action == "ltc_to_pco" else False
|
||
finally:
|
||
WAKE.set() # look again now, so the key shows the result
|