From f179df59f1e3b7130c9b2b392bfce15c1525bdd0 Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 22 Oct 2026 10:03:49 -0400 Subject: [PATCH] READY page: song keys, Waves Tune, live routing and in-ear sends - SONG KEYS: each song's PCO key against the set's locator, the pad under it and Finished Songs - W TUNE: the Waves Tune clip starting each song is the PCO key (and sends that key's program) - ROUTING / IN EARS: read live over AbletonOSC (then handed back to Companion); the hold, and auto-fix in its hours, put stems back on the routing template (from 261025 NA Project) and raise in-ear sends at -inf to 0 dB Co-Authored-By: Claude Opus 5.5 --- README.md | 23 +- config.json | 276 ++++++++++++++++++++++- readiness.py | 437 ++++++++++++++++++++++++++++++++++++- ready-page.companionconfig | Bin 3497 -> 4752 bytes schema.py | 16 ++ settings/ui/app.js | 4 + status_panel.py | 4 +- tests/test_readiness.py | 184 +++++++++++++++- 8 files changed, 923 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 5753e1f..d8dda48 100644 --- a/README.md +++ b/README.md @@ -126,12 +126,12 @@ Tap **READY** on the status page (row 2, col 7). Each key is one line of the wor against this weekend's plan in Planning Center by `readiness.py` (it looks every 2 minutes, and never while a HyperDeck is recording). **STATUS PAGE** (bottom left) goes back. -| | col 1 | col 2 | col 3 | col 4 | col 5 | -|--------|-------|-------|-------|-------|-------| -| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | -| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | | -| row 2 (backstage) | LINEUP TV | | | | | -| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | +| | col 1 | col 2 | col 3 | col 4 | col 5 | col 6 | col 7 | +|--------|-------|-------|-------|-------|-------|-------|-------| +| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE | +| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | | | | +| row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | | +| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | READY | The READY key at row 3 col 7 (and the one on the status page) shows how many keys still have something to do and the first of them. @@ -151,6 +151,10 @@ everything on the page at once. | TRACKS | this weekend's set exists (`Ableton/Projects/NA/261018 NA Project`), it has a song group for every PCO song, and Live last opened it ("LIVE HAS OLD SET") | looks again | | FOH MIX | **NOT MIXED** (amber): every track of a song is still at 0 dB. **TO MASTER**: a stem goes to Master instead of out to the console | looks again | | LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again | +| SONG KEYS | every song is in the key PCO gives it: the set's locator for it (`3- JEHOVAH - B`), the pad clip under it on `AMBIENT PADS` (`B - WARM - CHURCHFRONT PADS`) and a `Finished Songs` / `Finished Custom Songs` folder (`JEHOVAH [B]`). Eb and D# are the same key. **JEHOVAH B NOT Db** (red): the locator is in another key. **JEHOVAH PAD D NOT B** (red): the wrong pad. **JEHOVAH ONLY Db** (amber): Finished Songs has the song, but not in this key (save the new version there). A song that is not in Finished Songs at all is only noted. | looks again | +| W TUNE | the `WAVES TUNE` clip that starts each song (a clip from `Ableton/Waves Tune - midi Program Change`, sent to Waves Tune as a program change) is the song's PCO key; a minor song is fine on its relative major. **JEHOVAH TUNE Db** names the song; the details say which clip to put at which bar. **TUNE PGM**: a renamed clip that sends another key's program. | looks again (Live's control script cannot change a clip in the arrangement: swap the clip by hand) | +| ROUTING | read **live** from Ableton (AbletonOSC, so tracks just dragged in count before the set is saved): every stem goes where `ready.routing` says (Piano, Keys, Organ → `Ext. Out 5/6 Keys`; AG, EG, Bass → `7/8 GTRS`; Drums, Perc, FX, Synth Bass → `9/10 PERC / FX`; BGVS, Choir, Vox FX → `11/12 VOX`). The table was taken from 261025 NA Project and is edited on the settings page; "EG 13" goes where every other EG goes. **PIANO TO MASTER** (red) names the stem; **NEW STEM BANJO** (amber): a stem name the table does not know. | puts every stem back where the table says (and IN EARS' sends to 0 dB). Runs by itself in the auto-fix hours. | +| IN EARS | read live: no stem in a song group has its in-ear send (send A, to `A-IN EAR MIX`) at -inf, as songs dragged in from another set do. Levels someone set are left alone; `RESI TRACKS` and `MIDI GROUP` (`ready.ears_skip_groups`) are skipped. | sets those sends to 0 dB. Runs by itself in the auto-fix hours. | | MIDI | the Tracks Mac's MIDI links (FX LIGHTS, TRK LIVEBOX, TRK STUDIO B) and Ableton running | reconnects the MIDI link that dropped | | PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again | | TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start; the other locations' plans (`ready.ltc_campuses`: Apollo Beach, Southcoast, Woonsocket) get the same timecode for the same songs in this location's order, and a song that is missing or out of order there is left alone. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again | @@ -165,8 +169,13 @@ everything on the page at once. - **ProPresenter:** its network API. - **The Ableton set:** read over SSH on the Tracks Mac at low priority, and only re-read when the file changed. It reads the saved file, so save in Live, then hold the key to look again. - **Resolume:** its web server on port 8080. +- **Ableton, live (ROUTING, IN EARS):** AbletonOSC (the `AbletonOSC` control surface in Live, port 11000), asked from + the Tracks Mac itself. AbletonOSC answers whichever computer spoke to it last, and Companion's Ableton connection + listens for its answers on NA Ingest, so straight after each read NA Ingest asks it for the tempo and it answers + Companion again. -Nothing on the READY page writes to Companion, ProPresenter or the Ableton set. +Nothing on the READY page writes to Companion, ProPresenter or the Ableton set file. ROUTING and IN EARS' fix changes +only the open set in Live (outputs and send A), and only tracks whose name is still where it was a moment before. **Another location:** copy `"ready_page"`, `"ready"`, the four `ready_*` fixes and the READY tile from this config, then change these in `"ready"`: diff --git a/config.json b/config.json index 749759b..a48c5be 100644 --- a/config.json +++ b/config.json @@ -27,7 +27,8 @@ "atem_camera", "atem_midi", "spotify_play", - "ready_ltc" + "ready_ltc", + "ready_ableton_mix" ], "max_tries": 2, "pause_after_network_seconds": 60, @@ -496,6 +497,12 @@ "run": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul)", "short": "VISTA", "template": "win_start" + }, + "ready_ableton_mix": { + "action": "ableton_mix", + "kind": "ready", + "label": "put Ableton's stems back where the routing template says and their in-ear send (A) to 0 dB", + "short": "MIX" } }, "flap": { @@ -967,7 +974,212 @@ "vocal_positions": [ "Vocalist", "[Leader]" - ] + ], + "finished_folders": [ + "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/Finished Songs", + "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/Finished Custom Songs" + ], + "tune_library": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/Waves Tune - midi Program Change", + "ableton_osc": true, + "ears_send": 0, + "ears_skip_groups": [ + "RESI TRACKS", + "MIDI GROUP" + ], + "routing": { + "+GUIDE": [ + "Ext. Out", + "2 Guide" + ], + "+LOOPGUIDE": [ + "Master", + "" + ], + "AG": [ + "Ext. Out", + "7/8 GTRS" + ], + "AMBIENT PADS": [ + "Ext. Out", + "5/6 Keys" + ], + "AUX DRUMS (LIVE)": [ + "Ext. Out", + "9/10 PERC / FX" + ], + "BASS": [ + "Ext. Out", + "7/8 GTRS" + ], + "BGVS": [ + "Ext. Out", + "11/12 VOX" + ], + "BGVS 2": [ + "Ext. Out", + "11/12 VOX" + ], + "CHOIR": [ + "Ext. Out", + "11/12 VOX" + ], + "CHOIR 2": [ + "Ext. Out", + "11/12 VOX" + ], + "CHOIR 3": [ + "Ext. Out", + "11/12 VOX" + ], + "DRUMS (LIVE)": [ + "Ext. Out", + "9/10 PERC / FX" + ], + "EG 1": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 10": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 11": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 12": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 2": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 3": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 4": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 5": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 6": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 7": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 8": [ + "Ext. Out", + "7/8 GTRS" + ], + "EG 9": [ + "Ext. Out", + "7/8 GTRS" + ], + "FX": [ + "Ext. Out", + "9/10 PERC / FX" + ], + "KEYS 1": [ + "Ext. Out", + "5/6 Keys" + ], + "KEYS 2": [ + "Ext. Out", + "5/6 Keys" + ], + "KEYS 3": [ + "Ext. Out", + "5/6 Keys" + ], + "KEYS 4": [ + "Ext. Out", + "5/6 Keys" + ], + "LTC": [ + "Ext. Out", + "16 LTC" + ], + "ORGAN": [ + "Ext. Out", + "5/6 Keys" + ], + "PERC": [ + "Ext. Out", + "9/10 PERC / FX" + ], + "PIANO": [ + "Ext. Out", + "5/6 Keys" + ], + "PIANO 2": [ + "Ext. Out", + "5/6 Keys" + ], + "PIANO FX": [ + "Ext. Out", + "5/6 Keys" + ], + "RESI BASS": [ + "RESI TRACKS", + "" + ], + "RESI CLICK": [ + "RESI TRACKS", + "" + ], + "RESI DRUMS": [ + "RESI TRACKS", + "" + ], + "RESI GUIDE": [ + "RESI TRACKS", + "" + ], + "RESI KEYS": [ + "RESI TRACKS", + "" + ], + "RESI LTC": [ + "Ext. Out", + "16 LTC" + ], + "RESI TRACKS": [ + "Ext. Out", + "3/4 IN-EARS" + ], + "STRINGS": [ + "Ext. Out", + "5/6 Keys" + ], + "SYNTH BASS": [ + "Ext. Out", + "9/10 PERC / FX" + ], + "SYNTH GROUP": [ + "Ext. Out", + "5/6 Keys" + ], + "SYNTH PAD": [ + "Ext. Out", + "5/6 Keys" + ], + "TEMPO": [ + "Master", + "" + ], + "VOX FX": [ + "Ext. Out", + "11/12 VOX" + ] + } }, "ready_page": { "back_pos": [ @@ -1188,6 +1400,66 @@ 2 ] }, + { + "id": "rd_keys", + "label": "SONG KEYS", + "pos": [ + 0, + 6 + ], + "checks": [ + { + "type": "ready", + "item": "keys", + "fix": "ready_recheck" + } + ] + }, + { + "id": "rd_tune", + "label": "W TUNE", + "pos": [ + 0, + 7 + ], + "checks": [ + { + "type": "ready", + "item": "tune", + "fix": "ready_recheck" + } + ] + }, + { + "id": "rd_routing", + "label": "ROUTING", + "pos": [ + 2, + 2 + ], + "checks": [ + { + "type": "ready", + "item": "routing", + "fix": "ready_ableton_mix" + } + ] + }, + { + "id": "rd_ears", + "label": "IN EARS", + "pos": [ + 2, + 3 + ], + "checks": [ + { + "type": "ready", + "item": "ears", + "fix": "ready_ableton_mix" + } + ] + }, { "id": "rd_all", "kind": "ready", diff --git a/readiness.py b/readiness.py index 26faada..8fd9a24 100644 --- a/readiness.py +++ b/readiness.py @@ -9,6 +9,13 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p mix_foh every song is mixed (a song whose tracks are all at 0 dB is not) and its stems go out to the console (Ext. Out), not to Master lights the lighting cue track has a clip under every song + tune the Waves Tune clip that starts each song (a program change from the "Waves Tune - midi + Program Change" clips) is the song's key in PCO + routing every stem goes where this location's template says (Keys -> Ext. Out 5/6 Keys), read live + from Ableton over AbletonOSC, so tracks just dragged in count before the set is saved + ears no song stem's in-ear send (send A) is at -inf: dragged-in songs come in that way + keys every song is in the key PCO gives it: the set's locator for it ("3- JEHOVAH - B"), the pad + clip under it ("B - WARM - CHURCHFRONT PADS") and a Finished Songs folder ("JEHOVAH [B]") prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions match the slides (MC announcements: one slide each; baptisms: every name) timecode every song's presentation has a timeline, and PCO's LTC note for it is the timecode @@ -38,6 +45,8 @@ import json import logging import os import re +import socket +import struct import subprocess import threading import time @@ -231,7 +240,7 @@ def pco_plan(rc, now, service_type=None, team=True): note_ids[n.get("category_name") or ""] = ref["id"] out_items.append({"id": it.get("id"), "note_ids": note_ids, "title": a.get("title") or "", "type": a.get("item_type") or "", "position": a.get("service_position") or "", "description": a.get("description") or "", - "notes": ns}) + "notes": ns, "key": a.get("key_name") or ""}) members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid)) if team else ([], []) teams = {i["id"]: (i.get("attributes") or {}).get("name") for i in inc if i.get("type") == "Team"} team = [] @@ -445,14 +454,31 @@ def summary(path): ltc = {"start": float(g("CurrentStart")), "name": g("Name"), "warped": g("IsWarped") == "true", "offset": float(g("Loop/LoopStart") or 0)} break + pads = [] + for t in list(ls.find("Tracks")): + if t.find("Name/EffectiveName").get("Value").strip().upper() == P["pad_track"].strip().upper(): + for c in t.findall(".//ArrangerAutomation/Events/AudioClip"): + dis = c.find("Disabled") + if dis is None or dis.get("Value") != "true": + pads.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")), + c.find("Name").get("Value")]) + tune = [] + for t in list(ls.find("Tracks")): + if t.find("Name/EffectiveName").get("Value").strip().upper() == P["tune_track"].strip().upper(): + for c in t.findall(".//ArrangerAutomation/Events/MidiClip"): + dis = c.find("Disabled") + if dis is None or dis.get("Value") != "true": + pg = c.find("ProgramChange") + tune.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")), + c.find("Name").get("Value"), int(pg.get("Value")) if pg is not None else None]) return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events}, - "ltc": ltc} + "ltc": ltc, "pads": pads, "tune": tune} out = {"live_set": live_set(), "bundles": {}} path = newest_als(folder(P["site"])) out["set"] = path if path: st = os.stat(path) - key = [path, st.st_mtime, st.st_size, 2] # 2: the summary's layout + key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], 4] # 4: the summary's layout cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json") data = None try: @@ -471,6 +497,19 @@ if path: pass out["mtime"] = st.st_mtime out["summary"] = data +out["finished"] = {} +lib = os.path.expanduser(P["tune_library"]) if P["tune_library"] else "" +out["tune_library"] = {} +for n in sorted(os.listdir(lib)) if lib and os.path.isdir(lib) else []: + if n.endswith(".alc"): + try: + pg = ET.parse(gzip.open(os.path.join(lib, n))).getroot().find(".//MidiClip/ProgramChange") + out["tune_library"][n[:-4]] = int(pg.get("Value")) if pg is not None else None + except Exception: + pass +for f in P["finished"]: + f = os.path.expanduser(f) + out["finished"][f] = sorted(n for n in os.listdir(f) if "[" in n) if os.path.isdir(f) else None for loc in P["locations"]: d = folder(loc) out["bundles"][loc] = {"folder": d, "als": bool(newest_als(d)), "copying": os.path.isdir(d + ".copying")} @@ -481,7 +520,9 @@ print(json.dumps(out)) def ableton_params(rc, plan): return {"projects": rc.get("projects", ""), "site": rc.get("site_folder", ""), "folder": rc.get("folder_format", "{date} {loc} Project"), "date": datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"), "locations": rc.get("locations", []), - "ltc_track": rc.get("ltc_track", "LTC")} + "ltc_track": rc.get("ltc_track", "LTC"), "pad_track": rc.get("pad_track", "AMBIENT PADS"), + "finished": rc.get("finished_folders", []), "tune_track": rc.get("tune_track", "WAVES TUNE"), + "tune_library": rc.get("tune_library", "")} def ableton_look(cfg, rc, plan): @@ -563,6 +604,369 @@ def eval_ableton(rc, plan, look): return out +# --- Ableton, live (AbletonOSC on the Tracks Mac) ------------------------------------------ +# AbletonOSC answers on port 11001 of whichever computer spoke to it last, and sends its listeners' +# updates (Companion's volume and meter feedback) there too. So the set is read from the Tracks Mac itself +# (127.0.0.1), and straight after, NA Ingest asks it for the tempo: from then on it answers Companion again. + +LIVE_SCRIPT = r""" +import json, socket, struct, time +P = json.loads(%r) +def pad(b): + return b + b"\0" * (4 - len(b) %% 4) +def msg(addr, *args): + tags, data = ",", b"" + for a in args: + if isinstance(a, bool): + tags += "T" if a else "F" + elif isinstance(a, int): + tags, data = tags + "i", data + struct.pack(">i", a) + elif isinstance(a, float): + tags, data = tags + "f", data + struct.pack(">f", a) + else: + tags, data = tags + "s", data + pad(str(a).encode()) + return pad(addr.encode()) + pad(tags.encode()) + data +def parse(b): + def string(i): + j = b.index(b"\0", i) + return b[i:j].decode("utf-8", "replace"), (j // 4 + 1) * 4 + addr, i = string(0) + tags, i = string(i) + out = [] + for t in tags[1:]: + if t == "i": + out.append(struct.unpack(">i", b[i:i + 4])[0]); i += 4 + elif t == "f": + out.append(struct.unpack(">f", b[i:i + 4])[0]); i += 4 + elif t == "s": + s, i = string(i); out.append(s) + elif t in "TF": + out.append(t == "T") + elif t == "N": + out.append(None) + return addr, out +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 21) +try: + s.bind(("127.0.0.1", 11001)) +except OSError: + print(json.dumps({"error": "OSC PORT BUSY"})); raise SystemExit +LIVE = ("127.0.0.1", 11000) +def ask(reqs, timeout=4.0): + # send [(addr, args)]; the replies keyed by (addr, track index) + got = {} + for k in range(0, len(reqs), 40): + for a, args in reqs[k:k + 40]: + s.sendto(msg(a, *args), LIVE) + time.sleep(0.05) + want = len(reqs) + end = time.time() + timeout + s.settimeout(0.3) + while len(got) < want and time.time() < end: + try: + b, _ = s.recvfrom(65536) + except socket.timeout: + continue + a, args = parse(b) + if any(a == r[0] for r in reqs): + got[(a, args[0] if args and a.startswith("/live/track/") else None)] = args + return got +out = {} +g = ask([("/live/song/get/num_tracks", ()), ("/live/song/get/is_playing", ())]) +n = (g.get(("/live/song/get/num_tracks", None)) or [None])[0] +if n is None: + print(json.dumps({"error": "ABLETON NOT ANSWERING"})); raise SystemExit +out["playing"] = bool((g.get(("/live/song/get/is_playing", None)) or [False])[0]) +props = ["track.name", "track.is_foldable", "track.is_grouped", "track.has_audio_output"] +td = ask([("/live/song/get/track_data", (0, n) + tuple(props))]).get(("/live/song/get/track_data", None)) or [] +tracks = [dict(zip(("name", "group", "grouped", "audio"), td[i * 4:i * 4 + 4]), index=i) for i in range(len(td) // 4)] +audio = [t for t in tracks if t["audio"] and not t["group"]] +reqs = [] +for t in audio: + reqs += [("/live/track/get/output_routing_type", (t["index"],)), ("/live/track/get/output_routing_channel", (t["index"],)), + ("/live/track/get/send", (t["index"], P["send_index"]))] +g = ask(reqs, timeout=8.0) +for t in audio: + i = t["index"] + t["type"] = (g.get(("/live/track/get/output_routing_type", i)) or [None, None])[1] + t["channel"] = (g.get(("/live/track/get/output_routing_channel", i)) or [None, None])[1] + snd = g.get(("/live/track/get/send", i)) + t["send"] = snd[2] if snd and len(snd) > 2 else None +out["tracks"] = tracks +if P.get("apply"): + names = {t["index"]: t["name"] for t in tracks} + done, skipped = [], [] + for ch in P["apply"]: + i = ch["index"] + if names.get(i) != ch["name"]: + skipped.append(ch["name"]) # the set changed since the look: leave it for the next one + continue + if ch.get("type"): + s.sendto(msg("/live/track/set/output_routing_type", i, ch["type"]), LIVE) + time.sleep(0.15) + if ch.get("channel") is not None and ch.get("type") is not None: + s.sendto(msg("/live/track/set/output_routing_channel", i, ch["channel"]), LIVE) + if ch.get("send") is not None: + s.sendto(msg("/live/track/set/send", i, P["send_index"], float(ch["send"])), LIVE) + done.append(ch["name"]) + time.sleep(0.05) + out["done"], out["skipped"] = done, skipped +print(json.dumps(out)) +""" + + +def osc_message(addr, *args): + """One OSC message (strings and ints), for the hand-back to Companion.""" + def pad(b): + return b + b"\0" * (4 - len(b) % 4) + tags = "," + "".join("i" if isinstance(a, int) else "s" for a in args) + data = b"".join(struct.pack(">i", a) if isinstance(a, int) else pad(str(a).encode()) for a in args) + return pad(addr.encode()) + pad(tags.encode()) + data + + +def hand_back(cfg, host): + """AbletonOSC answers the last computer that spoke to it: speak to it from here, where Companion listens.""" + ip = (((cfg.get("hosts") or {}).get(host) or {}).get("ssh") or "").rpartition("@")[2] + if not ip: + return + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.sendto(osc_message("/live/song/get/tempo"), (ip, 11000)) + except OSError: + pass + + +def live_look(cfg, rc, apply=None): + host = rc.get("tracks_host") + try: + return run_remote(cfg, host, LIVE_SCRIPT % json.dumps({"send_index": rc.get("ears_send", 0), "apply": apply}), + timeout=30) + finally: + hand_back(cfg, host) + + +def stem_name(name): + """A track as the template knows it: "50-Keys 1 [2026-10-21 132128]" -> "KEYS 1".""" + s = re.sub(r"\[.*?\]", " ", (name or "").upper()) + s = re.sub(r"^\s*\d+\s*-\s*", "", s) + return " ".join(s.split()) + + +def template_for(routing, name): + """Where a stem goes: its own entry, else that of its family ("EG 13" goes where "EG 1".."EG 12" all go).""" + n = stem_name(name) + if n in routing: + return routing[n] + base = re.sub(r"\s*\d+$", "", n) + fam = {tuple(v) for k, v in routing.items() if re.sub(r"\s*\d+$", "", k) == base} + return list(fam.pop()) if len(fam) == 1 else None + + +def mix_plan(rc, live): + """(routing Line, ears Line, changes): what is routed away from the template, which song stems have + their in-ear send at -inf, and the changes that put both right.""" + routing, ears = Line(), Line() + if live.get("error"): + for line in (routing, ears): + line.fault(WARN, live["error"], "%s: AbletonOSC on the Tracks Mac did not answer" % live["error"]) + return routing, ears, [] + table = {stem_name(k): v for k, v in (rc.get("routing") or {}).items()} + skip = {norm(g) for g in rc.get("ears_skip_groups", [])} + changes, top, n_route, n_ears, new = {}, None, 0, 0, [] + for t in live.get("tracks") or []: + if t["group"] and not t["grouped"]: + top = t["name"] + continue + if not t["grouped"]: + top = None + if t["group"] or not t["audio"] or t.get("type") is None: + continue + want = template_for(table, t["name"]) if table else None + if want and [t["type"], t["channel"]] != list(want): + changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]}).update(type=want[0], channel=want[1]) + routing.fault(ERR, "%s TO %s" % (short(stem_name(t["name"]), 12), (t["channel"] or t["type"]).upper()[:10]), + "%s (%s) goes to %s %s; the template says %s %s" % (t["name"], top or "top level", t["type"], + t["channel"], want[0], want[1])) + elif want: + n_route += 1 + elif table and top and norm(top) not in skip: + new.append(t["name"]) + if top and norm(top) not in skip and t.get("send") is not None: + if t["send"] <= 0.0: + changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]})["send"] = 1.0 + ears.fault(ERR, "EARS OFF " + short(top, 12), "%s in %s: send A is at -inf" % (t["name"], top)) + else: + n_ears += 1 + if new: + routing.fault(WARN, "NEW STEM " + short(stem_name(new[0]), 12), "not in the routing template (add them in the " + "settings): " + ", ".join(new[:12])) + routing.info = "%d ROUTED" % n_route if table else "NO TEMPLATE" + ears.info = "%d SENDS ON" % n_ears + if live.get("playing"): + routing.notes.append("Ableton is playing") + return routing, ears, sorted(changes.values(), key=lambda change: change["index"]) + + +# --- Song keys -------------------------------------------------------------------------- + +NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} +KEY_AT_END = re.compile(r"-\s*([A-G](?:#|b|♯|♭)?m?)\s*$") # a locator: "3- JEHOVAH - B" +KEY_AT_START = re.compile(r"^\s*([A-G](?:#|b|♯|♭)?m?)(?=\s|-|_|$)") # a pad: "D# - WARM - CHURCHFRONT PADS" +FINISHED = re.compile(r"^(.*?)\s*\[\s*([A-G](?:#|b|♯|♭)?m?)\s*\]\s*(?:-\s*CUSTOM)?\s*$", re.I) # "JEHOVAH [Db]" + + +def key_of(text): + """A key as (pitch class, minor): "Eb" and "D#" are the same key; "C#m" is minor; None if it is not a key.""" + m = re.match(r"^\s*([A-Ga-g])(#|b|♯|♭)?\s*(m|min|minor)?\s*$", text or "") + if not m: + return None + pc = NOTES[m.group(1).upper()] + {"#": 1, "♯": 1, "b": -1, "♭": -1}.get(m.group(2) or "", 0) + return pc % 12, bool(m.group(3)) + + +def same_key(a, b, mode=True): + """Two keys that sound the same (a pad has no mode: mode=False compares the root alone).""" + ka, kb = key_of(a), key_of(b) + return bool(ka and kb) and ka[0] == kb[0] and (not mode or ka[1] == kb[1]) + + +def finished_versions(look, title): + """The keys Finished Songs has `title` in: an exact name if there is one, else a longer one + ("CAN'T HELP BUT PRAISE" for "Can't Help But Praise (Live)"). Unfinished and z-parked folders do not count.""" + exact, near = [], [] + for names in (look.get("finished") or {}).values(): + for n in names or []: + m = FINISHED.match(n) + if not m or "UNFINISHED" in n.upper(): + continue + (exact if norm(m.group(1)) == norm(title) else near if same(m.group(1), title) else []).append((n, m.group(2))) + return exact or near + + +def eval_keys(rc, plan, look): + """Every song in the key PCO gives it, by the set's locator, the pad under the song and Finished Songs.""" + line = Line() + if not look.get("set"): + date = ableton_params(rc, plan)["date"] + line.fault(ERR, "NO SET %s" % date, "no set for %s, so no keys to check" % date) + return line + summary = look["summary"] + groups = song_groups(summary) + pads = summary.get("pads") or [] + folders = [os.path.basename(f.rstrip("/")) for f in look.get("finished") or {}] + seen, ok = set(), 0 + for it in plan_songs(rc, plan): + title, want = it["title"], it.get("key") or "" + name = short(title, 14) + if not key_of(want): + line.fault(WARN, "NO KEY IN PCO " + short(title), "Planning Center has no key for %s" % title) + continue + gname = next((g for g in groups if same(title, g)), None) + if (gname or norm(title), key_of(want)) in seen: # "Owe You Praise TAG" is Owe You Praise again + continue + seen.add((gname or norm(title), key_of(want))) + bad, checked = False, 0 + loc = next((l for l in summary.get("locators") or [] if same(title, l["name"]) and KEY_AT_END.search(l["name"])), None) + if loc: + have = KEY_AT_END.search(loc["name"]).group(1) + checked += 1 + if not same_key(have, want): + bad = True + line.fault(ERR, "%s %s NOT %s" % (name, have, want), "Planning Center has %s in %s; the set's locator \"%s\" says %s" % ( + title, want, loc["name"].strip(), have)) + audio = [c for t in groups.get(gname, []) for c in t["clips"]] if gname else [] + if audio: + start, end = min(c[0] for c in audio), max(c[1] for c in audio) + under = [p for p in pads if p[0] < end and p[1] > start and KEY_AT_START.match(p[2] or "")] + if under: + pad = max(under, key=lambda p: min(p[1], end) - max(p[0], start)) + have = KEY_AT_START.match(pad[2]).group(1) + checked += 1 + if not same_key(have, want, mode=False): + bad = True + line.fault(ERR, "%s PAD %s NOT %s" % (name, have, want), "the pad under %s is %s (%s), but the song is in %s" % ( + gname, have, pad[2], want)) + if folders: + versions = finished_versions(look, title) + if not versions: + line.notes.append("%s is not in %s" % (title, " or ".join(folders))) + elif any(same_key(k, want) for _, k in versions): + checked += 1 + else: + line.fault(WARN, "%s ONLY %s" % (name, "/".join(sorted({k for _, k in versions}))), + "%s has %s, none in %s" % (" / ".join(folders), ", ".join(n for n, _ in versions), want)) + bad = True + if not checked and not bad: + line.fault(WARN, "KEY UNCHECKED " + short(title), "no locator with a key, no pad under it and nothing in Finished Songs " + "for %s (%s in PCO)" % (title, want)) + elif not bad: + ok += 1 + line.info = "%d KEYS OK" % ok + return line + + +def tune_key(name): + """The key a Waves Tune clip sets: "Ebmaj" -> "Eb", "C#min" -> "C#m"; None for "OFF | Chromatic" or "LOOP CUE".""" + m = re.match(r"^\s*([A-G](?:#|b)?)\s*(maj|min|m)?\b", name or "") + return (m.group(1) + ("m" if m.group(2) in ("min", "m") else "")) if m else None + + +def same_tune(clip_key, want): + """Waves Tune corrects to a scale: a minor song is fine on its relative major (Em on Gmaj).""" + if same_key(clip_key, want): + return True + kc, kw = key_of(clip_key), key_of(want) + return bool(kc and kw) and kw[1] and not kc[1] and (kw[0] + 3) % 12 == kc[0] + + +def eval_tune(rc, plan, look): + """The Waves Tune clip at each song's start is the song's PCO key (and the program it sends is that + key's program in the clip library, so a renamed clip is caught).""" + line = Line() + if not look.get("set"): + date = ableton_params(rc, plan)["date"] + line.fault(ERR, "NO SET %s" % date, "no set for %s" % date) + return line + summary = look["summary"] + track = rc.get("tune_track", "WAVES TUNE") + clips = sorted(summary.get("tune") or [], key=lambda c: c[0]) + if not clips: + line.fault(ERR, "NO TUNE CLIPS", "no clips on the %s track" % track) + return line + library = look.get("tune_library") or {} + groups = song_groups(summary) + seen, ok = set(), 0 + for it in plan_songs(rc, plan): + title, want = it["title"], it.get("key") or "" + gname = next((g for g in groups if same(title, g)), None) + audio = [c for t in groups.get(gname, []) for c in t["clips"]] if gname else [] + if not audio or not key_of(want) or gname in seen: + continue # TRACKS and SONG KEYS say what is missing + seen.add(gname) + start = min(c[0] for c in audio) + at = [c for c in clips if c[0] <= start + 1] + bar = "bar %d" % (int(start // 4) + 1) + name = short(title, 14) + if not at: + line.fault(ERR, "NO TUNE " + name, "no %s clip at or before %s, where %s starts" % (track, bar, gname)) + continue + clip = at[-1] + have = tune_key(clip[2]) + lib = next((n for n in library if tune_key(n) and same_tune(tune_key(n), want) and same_key(tune_key(n), have or "")), None) + right = next((n for n in library if tune_key(n) and same_tune(tune_key(n), want)), "%smaj" % want) + if not have or not same_tune(have, want): + line.fault(ERR, "%s TUNE %s" % (name, have or clip[2][:6].upper()), + "%s starts at %s on the \"%s\" clip, but the song is in %s: put the %s clip there instead" % ( + title, bar, clip[2], want, right)) + elif lib and library.get(lib) is not None and clip[3] is not None and clip[3] != library[lib]: + line.fault(ERR, "%s TUNE PGM" % name, "the \"%s\" clip at %s sends program %d, but %s is program %d" % ( + clip[2], bar, clip[3], lib, library[lib])) + else: + ok += 1 + line.info = "%d SONGS IN KEY" % ok + return line + + def eval_track_bundle(rc, look): line = Line() for loc, b in sorted((look.get("bundles") or {}).items()): @@ -1035,7 +1439,7 @@ def look(cfg, now=None, data_dir=None, sources=None): lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6), resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6), macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60), - companion=lambda n: companion_page(cfg, n), + companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc), prop_bundle=lambda plan: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps( {"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30)) src.update(sources or {}) @@ -1053,7 +1457,9 @@ def look(cfg, now=None, data_dir=None, sources=None): except Exception as e: # noqa: BLE001 - one source down must not hide the others plan = None failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")), - ("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")), + ("lights", rc.get("tracks_host")), ("keys", rc.get("tracks_host")), + ("tune", rc.get("tracks_host")), ("prop", rc.get("prop_url")), + ("routing", rc.get("ableton_osc")), ("ears", rc.get("ableton_osc")), ("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")), ("lineup", rc.get("lineup_url")), ("mics", True), ("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")), @@ -1085,6 +1491,8 @@ def look(cfg, now=None, data_dir=None, sources=None): try: al = src["ableton"](plan) lines.update(eval_ableton(rc, plan, al)) + lines["keys"] = eval_keys(rc, plan, al) + lines["tune"] = eval_tune(rc, plan, al) if rc.get("ltc_note", "LTC"): eval_ltc(rc, plan, al, lines.setdefault("timecode", Line())) na_songs = song_ltc(rc, plan, al) @@ -1107,7 +1515,12 @@ def look(cfg, now=None, data_dir=None, sources=None): if rc.get("locations"): lines["track_bundle"] = eval_track_bundle(rc, al) except Exception as e: # noqa: BLE001 - failed(["tracks", "mix_foh", "lights"] + (["track_bundle"] if rc.get("locations") else []), e) + failed(["tracks", "mix_foh", "lights", "keys", "tune"] + (["track_bundle"] if rc.get("locations") else []), e) + if rc.get("tracks_host") and rc.get("ableton_osc"): + try: + lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]()) + except Exception as e: # noqa: BLE001 + failed(["routing", "ears"], e) if rc.get("lineup_url"): try: lines["lineup"] = eval_lineup(rc, plan, src["lineup"]()) @@ -1234,6 +1647,16 @@ def run_action(cfg, action, chk=None, sleep=time.sleep): return "NO SET TO COPY" log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)") return True + if action == "ableton_mix": + _, _, changes = mix_plan(rc, live_look(cfg, rc)) + if not changes: + return True + r = live_look(cfg, rc, apply=changes) + if r.get("error"): + return r["error"] + log.info("FIX Ableton: %s put to the template / send A to 0 dB%s", ", ".join(r.get("done") or []) or "nothing", + ("; left (the set changed): " + ", ".join(r["skipped"])) if r.get("skipped") else "") + return True if r.get("done") else "SET CHANGED" log.warning("FIX ready: unknown action %r", action) return False except ReadyError as e: diff --git a/ready-page.companionconfig b/ready-page.companionconfig index d882e1664e64877fc7d82ee7a8ae871bab7a15d2..628979159f3843e52b80d851d109b3d0264c9043 100644 GIT binary patch literal 4752 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 S5}DJ*D;P+s=wzJe#{U5AFQ8=r literal 3497 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