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 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 07:30:36 -04:00
co-authored by Claude Opus 5.5
parent 0ecec5f112
commit 32e071e169
8 changed files with 330 additions and 7 deletions
+1
View File
@@ -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:**
+51 -1
View File
@@ -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,
+197 -1
View File
@@ -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"
Binary file not shown.
+5 -1
View File
@@ -433,6 +433,9 @@ field("ready.finished_folders", "list", [], "Folders of finished songs named \"S
"them: each song must have a version in the key Planning Center gives it.", "Ready",
example=["~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/Finished Songs"])
field("ready.locations", "list", [], "The locations that get this location's tracks.", "Ready")
field("ready.resi_vox", "dict", None, "RESI VOX: which mic the Resi song sends to the locations. {\"aja\": the Resi AJA's Dante "
"name, \"channel\": its Lead Vocal channel (21), \"mics\": the wireless receiver (BOH-AD4Q-A), \"buttons\": {VOX: [page, row, "
"col]} of Companion's VOX AJA buttons}. The lead comes from PCO's song description, their VOX from the lineup screen.", "Ready")
field("ready.start", "bool", None, "START: the week's Ableton sets (on by itself where this location sends its tracks to others). Every service Planning Center lists this week (this "
"location's weekend service and the special ones beside it: First / Together Tuesday, Morning Worship...) must have "
"its set; a hold builds the missing ones from the newest weekend set and Finished Songs.", "Ready")
@@ -554,7 +557,8 @@ GLOSSARY = {
"NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN",
"PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE",
"UNREACHABLE", "CHECK FAILED", "TURNED OFF", "COL", "COLUMNS OK", "NO COLUMNS USED", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX",
"START", "START FAILED", "BUILDING", "SAVING", "COPYING", "CONSOLIDATE", "NEW SONG", "NO SERVICES",
"RESI VOX", "RESI", "NOT VOX", "MIC WRONG", "NO RESI SONG", "NO LEAD", "NOT ON LINEUP", "LEADS", "PCO SAYS", "LEAD",
"NO BUTTON", "AJA NO ANSWER", "START", "START FAILED", "BUILDING", "SAVING", "COPYING", "CONSOLIDATE", "NEW SONG", "NO SERVICES",
"NO SERVICES IN PCO", "SERVICE ON", "ALREADY BUILDING", "DID NOT START", "NOTHING TO DO",
],
"ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"],
+1
View File
@@ -595,6 +595,7 @@
lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.",
track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.",
prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.",
resi_vox: "The Resi song (marked RESI in Planning Center, else the last song before the message) goes out to the locations with its lead singer's mic: the lead from the song's description (\"Genesis lead\"), their VOX from the lineup screen, and what the Resi AJA's Lead Vocal channel really receives (read from the card over Dante). Hold, or auto-fix, presses that VOX's AJA button on page 13. Two leads, a lead not on the lineup, or an Audio note in Planning Center that names another VOX are only flagged: a person decides.",
start: "Every service Planning Center lists this week (Sunday and any special service: First / Together Tuesday...) has its Ableton set. Hold START on Monday: it saves last weekend's songs to Finished Songs, then builds each set from the newest weekend set (locators, tempos, Waves Tune cues, songs, markers, guide, lighting cues, pads, routing, in-ear sends). A song in another key is transposed and named here, to consolidate in Live; a song not in Finished Songs is named, to build by hand.",
};
async function readyKeyPanel(i, hold) {
+2 -2
View File
@@ -820,9 +820,9 @@ def check_ready_manual(chk, snap, ctx):
# What readiness.py looks at (check type "ready") and what a "ready" fix can do.
READY_ITEMS = ("tracks", "mix_foh", "lights", "light_timing", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics",
"track_bundle", "prop_bundle", "start")
"track_bundle", "prop_bundle", "start", "resi_vox")
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix", "light_timing", "mount_share",
"start_week")
"start_week", "resi_vox")
CHECKS = {
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
+73 -2
View File
@@ -648,6 +648,7 @@ class TestLook(unittest.TestCase):
lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]),
resolume=composition, prop_bundle=lambda plan: [["Message Templates.probundle", WEDNESDAY]],
macros=macros_found, companion=lambda n: COMPANION.get(n, {}), live=live_set,
resi_now=lambda: (3, {"tx_channel": "03-VOX 3", "tx_device": "BOH-AD4Q-A", "name": "5 - Lead Vocal"}),
services=lambda: [SUNDAY_SVC], start=lambda svcs: {"survey": {SUNDAY_SVC["name"]: {"exists": True}}, "status": None})
src.update(over)
return src
@@ -672,7 +673,7 @@ class TestLook(unittest.TestCase):
def test_a_campus_that_only_receives(self):
cfg = json.loads(json.dumps(CONFIG))
for k in ("locations", "prop_bundle_host", "resolume_url", "lineup_url"):
for k in ("locations", "prop_bundle_host", "resolume_url", "lineup_url", "resi_vox"): # Resi goes out from NA only
cfg["ready"].pop(k)
cfg["ready"]["prop_url"] = ""
r = rd.look(cfg, now=WEDNESDAY, sources=self.sources())
@@ -732,7 +733,8 @@ class TestWatchdog(unittest.TestCase):
self.step(WEDNESDAY)
plan = self.engine.fix_plan("ready", self.snap)
fixes = sorted(what for _, kind, what, _ in plan if kind == "fix")
self.assertEqual(fixes, ["ready_ableton_mix", "ready_copy_tracks", "ready_led_cross", "ready_lineup", "ready_ltc", "ready_recheck"])
self.assertEqual(fixes, ["ready_ableton_mix", "ready_copy_tracks", "ready_led_cross", "ready_lineup", "ready_ltc", "ready_recheck",
"ready_resi_vox"]) # RESI VOX: the test plan names no lead
ran = []
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
self.engine.run_fixes("ready", self.snap, None, WEDNESDAY + 10)
@@ -911,3 +913,72 @@ class TestRunHere(unittest.TestCase):
def test_a_script_for_this_mac_runs_here_without_ssh(self):
cfg = {"hosts": {"ingest": {"self": True}}}
self.assertEqual(rd.run_remote(cfg, "ingest", "import json; print(json.dumps({'ok': 1}))"), {"ok": 1})
class TestResiVox(unittest.TestCase):
"""RESI VOX (2026-10-25): the Resi song goes to the locations with its lead's mic."""
RV = {"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]}}
LINEUP = {"state": {"slots": [{"label": "[LEADER, VOX 1, ACOUSTIC] MIX 1", "person": {"name": "Mike Powell"}},
{"label": "[VOX 2] MIX 2", "person": {"name": "Rosaliz Gaitan"}},
{"label": "[VOX 3] MIX 3", "person": {"name": "DJ McLean"}},
{"label": "[VOX 4] MIX 4", "person": {"name": "Genesis Gomez"}},
{"label": "[VOX 5] MIX 8", "person": {"name": "Bianca Barros"}},
{"label": "[DRUMS]", "person": {"name": "Luis Decamps"}}]}}
def plan(self, desc, audio=""):
notes = {"Audio": audio} if audio else {}
return {"items": [item("Worship", "header"), item("Jehovah", "song", description="DJ lead"),
item("Name Above All Names", "song", description=desc, notes=notes),
item("Message", "header"), item("Owe You Praise TAG", "song")], "team": []}
def run_eval(self, desc, now_vox, audio=""):
line, want = rd.eval_resi_vox({"resi_vox": self.RV}, self.plan(desc, audio), self.LINEUP, now_vox,
{"tx_channel": "0%s-VOX %s" % (now_vox, now_vox), "tx_device": "BOH-AD4Q-A", "name": "5 - Lead Vocal"})
return [m for _, m, _ in line.faults], line.info, want
def test_the_lead_on_the_right_mic(self):
self.assertEqual(self.run_eval("Genesis lead\nRESI TX", 4), ([], "VOX 4 GENESIS", 4))
def test_the_wrong_mic_is_red_and_says_which(self):
self.assertEqual(self.run_eval("Genesis lead\nRESI TX", 2)[0], ["RESI V2 NOT VOX 4"])
self.assertEqual(self.run_eval("Genesis lead\nRESI TX", 2)[2], 4)
def test_no_resi_mark_means_the_last_song_before_the_message(self):
self.assertEqual(rd.resi_song(self.plan("Rosaliz lead"))["title"], "Name Above All Names")
self.assertEqual(self.run_eval("Rosaliz lead", 2), ([], "VOX 2 ROSALIZ", 2))
def test_what_is_not_switched_by_itself(self):
self.assertEqual(self.run_eval("Mike and DJ lead", 1)[0], ["2 LEADS: MIKE DJ"]) # two leads: a person picks
self.assertEqual(self.run_eval("Genesis lead", 4, audio="V2")[0], ["PCO SAYS V2, LEAD V4"]) # PCO and the lineup differ
self.assertEqual(self.run_eval("Bianca lead", 4)[0], ["LEAD ON VOX 5: NO BUTTON"])
self.assertEqual(self.run_eval("Josh lead", 4)[0], ["JOSH NOT ON LINEUP"])
self.assertEqual(self.run_eval("*CONDENSED*", 4)[0], ["NO LEAD NAME ABOVE."])
for f in ("Genesis lead", "Mike and DJ lead", "Bianca lead"):
self.assertIsNone(self.run_eval(f, 4, audio="V2")[2]) if f != "Genesis lead" else None
def test_dante_rx_reads_the_subscription(self):
import socket
import struct
import threading
srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
srv.bind(("127.0.0.1", 0))
port = srv.getsockname()[1]
def answer():
req, addr = srv.recvfrom(64)
strings = b"\x00" * 12 + b"04-VOX 4\x00BOH-AD4Q-A\x005 - Lead Vocal\x00"
base = 12 + 16 * 20
recs = b"".join(struct.pack(">10H", 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"))