From 12a9e506e10b758f70caffe50fb9adb27a12cfa0 Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 27 Oct 2026 12:53:17 -0400 Subject: [PATCH] RESI VOX reads what each page 13 VOX AJA button really sends (now DQ-Rack 33-36) from Companion's store; a source no button sends is MIC WRONG Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- readiness.py | 45 ++++++++++++++++++++++++++++++++++++++++- tests/test_readiness.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4c442ce..0355ada 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,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, WS) 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). Two leads ("Mike and DJ lead"): the song's Audio note in PCO says whose mic goes out ("V3" or a name); without one it is flagged, never switched: **2 LEADS: MIKE DJ**. Also only flagged: **JOSH NOT ON LINEUP**, **LEAD ON VOX 5: NO BUTTON**. The lineup is what counts: a PCO Audio note naming another VOX is only mentioned in the details. | 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). | +| 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). Which Dante channel is "VOX n" is read from Companion's page 13 VOX n AJA button itself (today the DQ-Rack's 33-36 `DQ-VOX 1..4`), so changing those buttons needs no settings change; anything no button sends is **RESI MIC WRONG**. **RESI V2 NOT VOX 4** (red). Two leads ("Mike and DJ lead"): the song's Audio note in PCO says whose mic goes out ("V3" or a name); without one it is flagged, never switched: **2 LEADS: MIKE DJ**. Also only flagged: **JOSH NOT ON LINEUP**, **LEAD ON VOX 5: NO BUTTON**. The lineup is what counts: a PCO Audio note naming another VOX is only mentioned in the details. | 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**). **CONSOLIDATE THE LORD IS**: the saved set still has clips of that song transposed and not consolidated (read from the set file, so it goes once the set is saved consolidated). After a build, for a day: **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), then consolidates every transposed clip in Live (step 9 below). Never runs by itself. | **Where the answers come from:** diff --git a/readiness.py b/readiness.py index a689907..06374b3 100644 --- a/readiness.py +++ b/readiness.py @@ -2409,12 +2409,55 @@ def companion_var(cfg, conn, name): return r.read().decode("utf-8", "replace").strip() +def vox_sources(cfg, rv): + """{VOX n: (Dante device, channel number)}: what each VOX AJA button on Companion really sends, read from + Companion's own store (2026-10-27 the buttons were moved from the AD4Q-A's 1-4 to the DQ-Rack's 33-36 and the + panel kept expecting the AD4Q-A). Empty when it cannot be read.""" + import sqlite3 + db = os.path.expanduser((cfg.get("companion") or {}).get("db") or "~/Library/Application Support/companion/v5.0/db.sqlite") + out = {} + try: + con = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(db), uri=True, timeout=2) + try: + for n, (page, row, col) in (rv.get("buttons") or {}).items(): + pg = con.execute("select value from pages where id=?", (int(page),)).fetchone() + cid = ((json.loads(pg[0]).get("controls") or {}).get(str(row)) or {}).get(str(col)) if pg else None + ctl = con.execute("select value from controls where id=?", (cid,)).fetchone() if cid else None + for step in (json.loads(ctl[0]).get("steps") or {}).values() if ctl else []: + for acts in (step.get("action_sets") or {}).values(): + for a in acts or []: + o = a.get("options") or {} + dev = (o.get("sourceDevice") or {}).get("value") + if (o.get("destinationDevice") or {}).get("value") == rv["aja"] and dev: + ch = (o.get("sourceChannel_" + dev) or {}).get("value") + if ch: + out[int(n)] = (dev, int(ch)) + finally: + con.close() + except (sqlite3.Error, ValueError, KeyError, TypeError, AttributeError): + return {} + return out + + 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") + conn = rv.get("dante_conn", "audinate-dantecontroller") + ip = companion_var(cfg, conn, 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") + every = vox_sources(cfg, rv) + srcs = {k: v for k, v in every.items() if v[0] == got["tx_device"]} + if every and not srcs: + return None, got # not what any VOX button sends: a hold presses the lead's + if srcs: + # The channel's number on its device: from the device's channel names, else the name's own number ("33 DQ-VOX 1"). + try: + ch = json.loads(companion_var(cfg, conn, got["tx_device"] + "_tx_names")).index(got["tx_channel"]) + 1 + except (ValueError, OSError): + m = re.match(r"^\s*(\d+)\b", got["tx_channel"] or "") + ch = int(m.group(1)) if m else None + return next((k for k, (_, c) in srcs.items() if c == ch), None), got if got["tx_device"] != rv.get("mics", "BOH-AD4Q-A"): return None, got n = vox_of(got["tx_channel"]) diff --git a/tests/test_readiness.py b/tests/test_readiness.py index e2f4aaf..3bc3b16 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -986,6 +986,39 @@ class TestResiVox(unittest.TestCase): 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_the_buttons_say_where_each_vox_comes_from(self): + """2026-10-27: the VOX AJA buttons now send the DQ-Rack's 33-36; the panel reads what each button sends.""" + import sqlite3 + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + db = os.path.join(d, "db.sqlite") + con = sqlite3.connect(db) + con.execute("create table pages (id integer, value text)") + con.execute("create table controls (id text, value text)") + con.execute("insert into pages values (13, ?)", (json.dumps({"controls": {"3": {str(n): "bank:v%d" % n for n in range(1, 5)}}}),)) + for n in range(1, 5): + act = {"definitionId": "makeCrosspointDropDown", "options": { + "destinationDevice": {"value": "STUDIO-A-Resi-AJA-SDI"}, "sourceDevice": {"value": "BOH-DQ-Rack"}, + "sourceChannel_BOH-DQ-Rack": {"value": 32 + n}, "sourceChannel_BOH-AD4Q-A": {"value": n}}} + con.execute("insert into controls values (?, ?)", ("bank:v%d" % n, json.dumps({"steps": {"0": {"action_sets": {"down": [act]}}}}))) + con.commit() + con.close() + cfg = {"companion": {"db": db}} + self.assertEqual(rd.vox_sources(cfg, self.RV), {n: ("BOH-DQ-Rack", 32 + n) for n in range(1, 5)}) + names = ["%02d DQ-CH" % i for i in range(1, 33)] + ["33 DQ-VOX 1", "34 DQ-VOX 2", "35 DQ-VOX 3", "36 DQ-VOX 4"] + got = {"tx_channel": "33 DQ-VOX 1", "tx_device": "BOH-DQ-Rack", "name": "5 - Lead Vocal"} + orig = rd.companion_var, rd.dante_rx + rd.companion_var = lambda cfg, conn, name: json.dumps(names) if name.endswith("_tx_names") else "169.254.54.105" + rd.dante_rx = lambda ip, ch: dict(got) + try: + self.assertEqual(rd.resi_now(cfg, self.RV)[0], 1) + got["tx_channel"] = "36 DQ-VOX 4" + self.assertEqual(rd.resi_now(cfg, self.RV)[0], 4) + got.update(tx_channel="01-VOX 1", tx_device="BOH-AD4Q-A") # the old source: not a button's now + self.assertIsNone(rd.resi_now(cfg, self.RV)[0]) + finally: + rd.companion_var, rd.dante_rx = orig + 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))