From 32e071e169a788b755ff1a126e64a6c1246a7399 Mon Sep 17 00:00:00 2001 From: christian Date: Sun, 25 Oct 2026 07:30:36 -0400 Subject: [PATCH] READY: RESI VOX - the Resi song goes out with its lead singer's mic 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 --- README.md | 1 + config.json | 52 +++++++++- readiness.py | 198 ++++++++++++++++++++++++++++++++++++- ready-page.companionconfig | Bin 5040 -> 5185 bytes schema.py | 6 +- settings/ui/app.js | 1 + status_panel.py | 4 +- tests/test_readiness.py | 75 +++++++++++++- 8 files changed, 330 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a792dfb..ceb9e80 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ everything on the page at once. | LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center | | TRACKS OUT | every location in `"locations"` (AB, SC) has this weekend's project folder | copies NA's project to the locations that have none (as `261018 AB Project`, without `Backup/`). It is copied under a temporary name and renamed at the end, so a half-finished copy never looks sent. It never overwrites a folder that is already there and never runs by itself. | | PROP OUT | a `.probundle` is in this weekend's folder of the Chronicles share (`/Volumes/Location Assets/261025`, read on the ProPresenter Mac). **SHARE NOT MOUNTED**: the share itself is not mounted there (2026-10-22: it had dropped off and the key said NOT IN 261025 although the bundle was there). The ProP Mac keeps it mounted by itself: a LaunchAgent (`org.waterschurch.keep-mounted.location-assets`, from `remote-access/mac-keep-mounted.sh`) looks every minute and at login, mounts it again with the Chronicles login saved in the Keychain, and unmounts and remounts it when it hangs after sleep or a network drop (log: `~/Library/Logs/keep-mounted-location-assets.log` on the ProP Mac). | starts that agent now and waits for the share (about 6 s), then looks again. Also an auto-fix. | +| RESI VOX | the Resi song (PCO description says RESI, else the last song before the message) goes to the locations with its lead singer's mic: the lead from the description ("Genesis lead"), their VOX from the lineup screen ("[VOX 4] MIX 4"; PCO team positions if the lineup has none), against what the Resi AJA's `5 - Lead Vocal` channel (21) really receives, read from the card with Dante's own query (UDP 4440, read only). **RESI V2 NOT VOX 4** (red). Only flagged, never switched: **2 LEADS: MIKE DJ**, **JOSH NOT ON LINEUP**, **PCO SAYS V2, LEAD V4** (the song's Audio note names another VOX), **LEAD ON VOX 5: NO BUTTON**. | presses that VOX's AJA button (Companion page 13, row 3, col 1-4: AJA ch 21 <- BOH-AD4Q-A ch n), then reads the AJA back. Also an auto-fix (any time, never while a deck records: the READY page is not looked at then). | | START | every service Planning Center lists this week (this location's Sunday and every special service: First / Together Tuesday, Morning Worship, ...) has its Ableton set. **NO SET SUN 11/1** (red when due): hold START. While it builds the key shows what it is doing (**SAVING JEHOVAH**, **COPYING 40/120**). After a build, for a day: **CONSOLIDATE THE LORD IS** (a song transposed to its PCO key: consolidate it in Live), **NEW SONG GRACE** (not in Finished Songs: build it by hand, page 3 of the workflow PDF). See *START* below. | builds the missing sets (on the Tracks Mac, in the background). Never runs by itself. | **Where the answers come from:** diff --git a/config.json b/config.json index e75e1dd..50c0d21 100644 --- a/config.json +++ b/config.json @@ -35,6 +35,7 @@ "atem_midi", "spotify_play", "ready_ltc", + "ready_resi_vox", "ready_ableton_mix", "ready_mount_share" ], @@ -545,6 +546,13 @@ "run": "pgrep -xq AbleSet || { open -g -a AbleSet; i=0; while [ $i -lt 20 ]; do sleep 1; i=$((i+1)); n=$(osascript -e 'tell application \"System Events\" to count windows of process \"AbleSet\"' 2>/dev/null); if [ \"${n:-0}\" -gt 0 ]; then osascript -e 'tell application \"System Events\" to tell process \"AbleSet\" to set value of attribute \"AXMinimized\" of every window to true' >/dev/null 2>&1; break; fi; done; }", "short": "ABLESET", "template": "mac_start" + }, + "ready_resi_vox": { + "action": "resi_vox", + "kind": "ready", + "anytime": true, + "short": "RESI VOX", + "label": "send the Resi song's lead singer's mic over Resi: press their VOX AJA button on page 13 (from PCO's lead and the lineup screen)" } }, "flap": { @@ -1222,7 +1230,34 @@ "11/12 VOX" ] }, - "light_library": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI" + "light_library": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI", + "resi_vox": { + "aja": "STUDIO-A-Resi-AJA-SDI", + "channel": 21, + "mics": "BOH-AD4Q-A", + "buttons": { + "1": [ + 13, + 3, + 1 + ], + "2": [ + 13, + 3, + 2 + ], + "3": [ + 13, + 3, + 3 + ], + "4": [ + 13, + 3, + 4 + ] + } + } }, "ready_page": { "back_pos": [ @@ -1541,6 +1576,21 @@ "fix": "ready_start" } ] + }, + { + "id": "rd_resi_vox", + "label": "RESI VOX", + "pos": [ + 2, + 4 + ], + "checks": [ + { + "type": "ready", + "item": "resi_vox", + "fix": "ready_resi_vox" + } + ] } ], "page": 21, diff --git a/readiness.py b/readiness.py index f85058b..7a43a5d 100644 --- a/readiness.py +++ b/readiness.py @@ -30,6 +30,10 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p 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) @@ -1750,7 +1754,7 @@ def look(cfg, now=None, data_dir=None, sources=None): 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), + 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 {}) @@ -1856,6 +1860,16 @@ def look(cfg, now=None, data_dir=None, sources=None): 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 @@ -2083,6 +2097,186 @@ def eval_start(rc, services, look, now): 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): @@ -2170,6 +2364,8 @@ def run_action(cfg, action, chk=None, sleep=time.sleep): 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" diff --git a/ready-page.companionconfig b/ready-page.companionconfig index c7685049482bfeeb6fe5787ab4cfa9c2c0e20627..a655eecf80b157632292246bc220d0eb07d0c79d 100644 GIT binary patch literal 5185 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 WQVOZkrjz{(Qbm%iB7ZhAlK%l2CxE*E literal 5040 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", 17 + i, 14, 0, 0, 0, 0, 0x101, 9, 0, 0) for i in range(4)) + recs += struct.pack(">10H", 21, 14, 0, base + 12, base + 21, base + 32, 0x101, 9, 0, 0) + recs += b"".join(struct.pack(">10H", 22 + i, 14, 0, 0, 0, 0, 0x101, 9, 0, 0) for i in range(11)) + srv.sendto(req[:4] + req[4:6] + req[6:8] + b"\0" * 4 + recs + strings, addr) + threading.Thread(target=answer, daemon=True).start() + orig = rd.DANTE_ARC + rd.DANTE_ARC = port + try: + got = rd.dante_rx("127.0.0.1", 21) + finally: + rd.DANTE_ARC = orig + srv.close() + self.assertEqual((got["name"], got["tx_channel"], got["tx_device"]), ("5 - Lead Vocal", "04-VOX 4", "BOH-AD4Q-A"))