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 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:03:49 -04:00
co-authored by Claude Opus 5.5
parent 51a14140cc
commit f179df59f1
8 changed files with 923 additions and 21 deletions
+16 -7
View File
@@ -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 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. HyperDeck is recording). **STATUS PAGE** (bottom left) goes back.
| | col 1 | col 2 | col 3 | col 4 | col 5 | | | 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 | | 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 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | | | |
| row 2 (backstage) | LINEUP TV | | | | | | row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | |
| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | 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 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. 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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. - **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. - **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. - **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, **Another location:** copy `"ready_page"`, `"ready"`, the four `ready_*` fixes and the READY tile from this config,
then change these in `"ready"`: then change these in `"ready"`:
+274 -2
View File
@@ -27,7 +27,8 @@
"atem_camera", "atem_camera",
"atem_midi", "atem_midi",
"spotify_play", "spotify_play",
"ready_ltc" "ready_ltc",
"ready_ableton_mix"
], ],
"max_tries": 2, "max_tries": 2,
"pause_after_network_seconds": 60, "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)", "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", "short": "VISTA",
"template": "win_start" "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": { "flap": {
@@ -967,7 +974,212 @@
"vocal_positions": [ "vocal_positions": [
"Vocalist", "Vocalist",
"[Leader]" "[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": { "ready_page": {
"back_pos": [ "back_pos": [
@@ -1188,6 +1400,66 @@
2 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", "id": "rd_all",
"kind": "ready", "kind": "ready",
+430 -7
View File
@@ -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 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 stems go out to the console (Ext. Out), not to Master
lights the lighting cue track has a clip under every song 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 prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions
match the slides (MC announcements: one slide each; baptisms: every name) 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 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 logging
import os import os
import re import re
import socket
import struct
import subprocess import subprocess
import threading import threading
import time 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"] 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 "", 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 "", "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 ([], []) 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"} teams = {i["id"]: (i.get("attributes") or {}).get("name") for i in inc if i.get("type") == "Team"}
team = [] team = []
@@ -445,14 +454,31 @@ def summary(path):
ltc = {"start": float(g("CurrentStart")), "name": g("Name"), "warped": g("IsWarped") == "true", ltc = {"start": float(g("CurrentStart")), "name": g("Name"), "warped": g("IsWarped") == "true",
"offset": float(g("Loop/LoopStart") or 0)} "offset": float(g("Loop/LoopStart") or 0)}
break 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}, 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": {}} out = {"live_set": live_set(), "bundles": {}}
path = newest_als(folder(P["site"])) path = newest_als(folder(P["site"]))
out["set"] = path out["set"] = path
if path: if path:
st = os.stat(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") cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json")
data = None data = None
try: try:
@@ -471,6 +497,19 @@ if path:
pass pass
out["mtime"] = st.st_mtime out["mtime"] = st.st_mtime
out["summary"] = data 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"]: for loc in P["locations"]:
d = folder(loc) d = folder(loc)
out["bundles"][loc] = {"folder": d, "als": bool(newest_als(d)), "copying": os.path.isdir(d + ".copying")} 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): def ableton_params(rc, plan):
return {"projects": rc.get("projects", ""), "site": rc.get("site_folder", ""), "folder": rc.get("folder_format", "{date} {loc} Project"), 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", []), "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): def ableton_look(cfg, rc, plan):
@@ -563,6 +604,369 @@ def eval_ableton(rc, plan, look):
return out 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): def eval_track_bundle(rc, look):
line = Line() line = Line()
for loc, b in sorted((look.get("bundles") or {}).items()): 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), 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), 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), 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( 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)) {"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
src.update(sources or {}) 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 except Exception as e: # noqa: BLE001 - one source down must not hide the others
plan = None plan = None
failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")), 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")), ("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
("lineup", rc.get("lineup_url")), ("mics", True), ("lineup", rc.get("lineup_url")), ("mics", True),
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")), ("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: try:
al = src["ableton"](plan) al = src["ableton"](plan)
lines.update(eval_ableton(rc, plan, al)) 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"): if rc.get("ltc_note", "LTC"):
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line())) eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
na_songs = song_ltc(rc, plan, al) 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"): if rc.get("locations"):
lines["track_bundle"] = eval_track_bundle(rc, al) lines["track_bundle"] = eval_track_bundle(rc, al)
except Exception as e: # noqa: BLE001 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"): if rc.get("lineup_url"):
try: try:
lines["lineup"] = eval_lineup(rc, plan, src["lineup"]()) 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" return "NO SET TO COPY"
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)") log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
return True 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) log.warning("FIX ready: unknown action %r", action)
return False return False
except ReadyError as e: except ReadyError as e:
Binary file not shown.
+16
View File
@@ -396,6 +396,22 @@ field("ready.folder_format", "str", "{date} {loc} Project", "A weekend's project
field("ready.ltc_track", "str", "LTC", "The Ableton track that plays the timecode (an LTC file named like " field("ready.ltc_track", "str", "LTC", "The Ableton track that plays the timecode (an LTC file named like "
"LTC_00000000_60mins_30fps: it starts at 00:00:00:00, 30 frames a second).", "Ready", advanced=True) "LTC_00000000_60mins_30fps: it starts at 00:00:00:00, 30 frames a second).", "Ready", advanced=True)
field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "Ready") field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "Ready")
field("ready.pad_track", "str", "AMBIENT PADS", "The Ableton track whose pad clips are named by key (\"B - WARM - "
"CHURCHFRONT PADS\"): the pad under each song must be in the song's key.", "Ready")
field("ready.tune_track", "str", "WAVES TUNE", "The Ableton track whose MIDI clips set Waves Tune's key per song "
"(clips named like \"Ebmaj\").", "Ready")
field("ready.tune_library", "path", "", "The folder of Waves Tune key clips (Ebmaj.alc ...), as the tracks computer sees "
"it: the program each key sends.", "Ready",
example="~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/Waves Tune - midi Program Change")
field("ready.ableton_osc", "bool", False, "Read Ableton live over AbletonOSC (port 11000 on the tracks computer) for "
"ROUTING and IN EARS, and let their hold put them right.", "Ready")
field("ready.routing", "dict", {}, "Where each stem goes: {\"KEYS 1\": [\"Ext. Out\", \"5/6 Keys\"], ...}. Names "
"without the track number and date; \"EG 13\" goes where every other EG goes.", "Ready")
field("ready.ears_send", "int", 0, "The in-ear send (0 = A): no song stem may have it at -inf.", "Ready", lo=0, hi=11)
field("ready.ears_skip_groups", "list", [], "Groups whose tracks have no in-ear send of their own (RESI TRACKS).", "Ready")
field("ready.finished_folders", "list", [], "Folders of finished songs named \"SONG [KEY]\", as the tracks computer sees "
"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.locations", "list", [], "The locations that get this location's tracks.", "Ready")
field("ready.remote_timeout", "int", 90, "Seconds allowed for reading the Ableton set.", "Ready", advanced=True, field("ready.remote_timeout", "int", 90, "Seconds allowed for reading the Ableton set.", "Ready", advanced=True,
lo=10, hi=600) lo=10, hi=600)
+4
View File
@@ -581,6 +581,10 @@
tracks: "This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.", tracks: "This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.",
mix_foh: "Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.", mix_foh: "Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.",
lights: "The lighting cue track has a clip under every song.", lights: "The lighting cue track has a clip under every song.",
tune: "The Waves Tune clip that starts each song is the song's Planning Center key, and sends that key's program. Swap the clip by hand (the hold says which and where); a minor song is fine on its relative major.",
routing: "Read live from Ableton (AbletonOSC): every stem goes where the routing template says (Keys → Ext. Out 5/6 Keys). Hold to put them back.",
ears: "Read live from Ableton: no song stem has its in-ear send (send A) at -inf, as dragged-in songs do. Hold to set them to 0 dB.",
keys: "Every song is in the key Planning Center gives it: the set's locator for the song (\"3- JEHOVAH - B\"), the pad clip under it, and a Finished Songs folder (\"JEHOVAH [B]\"). Eb and D# count as the same key.",
prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.", prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.",
timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.", timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.",
backgrounds: "Every song has at least one slide with a background.", backgrounds: "Every song has at least one slide with a background.",
+2 -2
View File
@@ -817,9 +817,9 @@ def check_ready_manual(chk, snap, ctx):
# What readiness.py looks at (check type "ready") and what a "ready" fix can do. # What readiness.py looks at (check type "ready") and what a "ready" fix can do.
READY_ITEMS = ("tracks", "mix_foh", "lights", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics", READY_ITEMS = ("tracks", "mix_foh", "lights", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics",
"track_bundle", "prop_bundle") "track_bundle", "prop_bundle")
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco") READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix")
CHECKS = { CHECKS = {
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure, "conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
+181 -3
View File
@@ -256,6 +256,184 @@ class TestAbleton(unittest.TestCase):
self.assertEqual(json.loads(params)["date"], "261018") self.assertEqual(json.loads(params)["date"], "261018")
FINISHED = "/Drive/Ableton/Finished Songs"
def keyed(keys):
"""plan() with PCO keys on its songs ({title: key})."""
p = plan()
for it in p["items"]:
it["key"] = keys.get(it["title"], "")
return p
def with_pads_and_finished(al=None):
"""The set with the real pads of 261025 (named by key) under its songs, and a slice of Finished Songs."""
al = al or ableton()
al["summary"]["pads"] = [[0, 95, "A# - WARM - CHURCHFRONT PADS"], [95, 196, "D# - WARM - CHURCHFRONT PADS"],
[196, 300, "B - WARM - CHURCHFRONT PADS"], [300, 400, "B - WARM - CHURCHFRONT PADS"]]
al["finished"] = {FINISHED: ["CAN'T HELP BUT PRAISE LM [Eb]", "CAN'T HELP BUT PRAISE [D]", "CAN'T HELP BUT PRAISE [Eb]",
"COVERED BY THE BLOOD [B]", "HERE AGAIN UNFINISHED [C]", "HOLY FOREVER [B]",
"REJOICE AGAIN [B]", "zHOLY FOREVER [F]"]}
return al
GOOD = {"Rejoice Again": "Bb", "Can't Help But Praise (Live)": "Eb", "Holy Forever": "B", "Covered By The Blood TAG": "B"}
class TestKeys(unittest.TestCase):
def test_key_names(self):
self.assertTrue(rd.same_key("Eb", "D#"))
self.assertTrue(rd.same_key("Bb", "A#"))
self.assertFalse(rd.same_key("B", "Bb"))
self.assertFalse(rd.same_key("Em", "E"))
self.assertTrue(rd.same_key("Em", "E", mode=False))
self.assertIsNone(rd.key_of("LOOP"))
def test_every_song_in_its_key(self):
al = with_pads_and_finished()
al["finished"][FINISHED].append("REJOICE AGAIN [Bb]")
line = rd.eval_keys(RC, keyed(GOOD), al)
self.assertEqual(msgs(line), [])
self.assertEqual(line.info, "4 KEYS OK")
def test_the_set_in_another_key(self):
keys = dict(GOOD, **{"Holy Forever": "C"})
line = rd.eval_keys(RC, keyed(keys), with_pads_and_finished())
# Rejoice Again: Finished Songs only has it in B (PCO and the set say Bb)
self.assertEqual(msgs(line), ["REJOICE AGAIN ONLY B", "HOLY FOREVER B NOT C", "HOLY FOREVER PAD B NOT C",
"HOLY FOREVER ONLY B"])
self.assertEqual([lv for lv, _, _ in line.faults], [rd.WARN, rd.ERR, rd.ERR, rd.WARN])
def test_a_song_pco_has_no_key_for(self):
line = rd.eval_keys(RC, keyed(dict(GOOD, **{"Holy Forever": ""})), with_pads_and_finished())
self.assertIn("NO KEY IN PCO HOLY FOREVER", msgs(line))
def test_nothing_to_check_a_key_by(self):
al = ableton()
al["summary"]["locators"] = [{"name": "HOLY FOREVER", "time": 200}]
al["summary"]["tracks"] = [t for t in al["summary"]["tracks"] if t["id"] in ("30", "31")]
line = rd.eval_keys(RC, keyed(GOOD), al)
self.assertEqual(msgs(line), ["KEY UNCHECKED REJOICE AGAIN", "KEY UNCHECKED CANT HELP BUT P.",
"KEY UNCHECKED HOLY FOREVER", "KEY UNCHECKED COVERED BY THE."])
def test_a_song_missing_from_finished_songs_is_only_noted(self):
al = with_pads_and_finished()
al["finished"][FINISHED].remove("HOLY FOREVER [B]")
line = rd.eval_keys(RC, keyed(GOOD), al)
self.assertNotIn("HOLY FOREVER", " ".join(msgs(line)))
self.assertIn("Holy Forever is not in Finished Songs", line.as_dict()["details"])
def test_no_set(self):
self.assertEqual(msgs(rd.eval_keys(RC, keyed(GOOD), {"set": None})), ["NO SET 261018"])
def live(name, index, grouped=True, type_="Ext. Out", channel="5/6 Keys", send=1.0, group=False, audio=True):
return {"name": name, "index": index, "group": group, "grouped": grouped, "audio": audio, "type": type_,
"channel": channel, "send": send}
def live_set(**over):
"""What AbletonOSC said about 261025 NA Project on 2026-10-22, trimmed: a song dragged in last (its stems'
in-ear sends at -inf, one routed to Master) and a new stem name."""
tracks = [
live("TEMPO", 0, False, "Master", "", 0.0),
live("LTC", 1, False, "Ext. Out", "16 LTC", 0.0),
live("MIDI GROUP", 2, False, group=True),
live("WAVES TUNE", 3, audio=False),
live("RESI TRACKS", 4, False, group=True),
live("RESI TRACKS", 5, True, "Ext. Out", "3/4 IN-EARS", 0.0),
live("RESI KEYS", 6, True, "RESI TRACKS", "", 0.0),
live("AMBIENT PADS", 7, False, "Ext. Out", "5/6 Keys", 0.36),
live("CAN'T HELP BUT PRAISE", 8, False, group=True),
live("21-Piano", 9),
live("22-Piano 2", 10, send=0.75),
live("29-EG 1", 11, channel="7/8 GTRS", send=0.44),
live("JEHOVAH", 12, False, group=True),
live("50-Keys 1 [2026-10-21 132128]", 13, send=0.0),
live("56-EG 13 [2026-10-21 132128]", 14, channel="7/8 GTRS", send=0.0),
live("62-Bass [2026-10-21 132128]", 15, True, "Master", "", 0.0),
live("70-Banjo [2026-10-21 132128]", 16, channel="7/8 GTRS", send=0.5),
]
out = {"playing": False, "tracks": tracks}
out.update(over)
return out
class TestAbletonLive(unittest.TestCase):
def test_routing_and_in_ears(self):
routing, ears, changes = rd.mix_plan(RC, live_set())
self.assertEqual(msgs(routing), ["BASS TO MASTER", "NEW STEM BANJO"])
self.assertEqual(msgs(ears), ["EARS OFF JEHOVAH"])
self.assertIn("50-Keys 1 [2026-10-21 132128] in JEHOVAH: send A is at -inf", ears.as_dict()["details"])
# EG 13 is not in the template, but every EG goes to 7/8 GTRS; RESI and top-level tracks keep their -inf sends
self.assertEqual(changes, [
{"index": 13, "name": "50-Keys 1 [2026-10-21 132128]", "send": 1.0},
{"index": 14, "name": "56-EG 13 [2026-10-21 132128]", "send": 1.0},
{"index": 15, "name": "62-Bass [2026-10-21 132128]", "type": "Ext. Out", "channel": "7/8 GTRS", "send": 1.0}])
self.assertEqual(ears.info, "4 SENDS ON")
def test_stem_names(self):
self.assertEqual(rd.stem_name("50-Keys 1 [2026-10-21 132128]"), "KEYS 1")
self.assertEqual(rd.stem_name("105-Drums (Live)"), "DRUMS (LIVE)")
self.assertEqual(rd.template_for(RC["routing"], "88-Drums (Live) [2026-10-19 132517]"), ["Ext. Out", "9/10 PERC / FX"])
self.assertIsNone(rd.template_for(RC["routing"], "Banjo"))
def test_ableton_not_answering(self):
routing, ears, changes = rd.mix_plan(RC, {"error": "ABLETON NOT ANSWERING"})
self.assertEqual((msgs(routing), msgs(ears), changes), (["ABLETON NOT ANSWERING"], ["ABLETON NOT ANSWERING"], []))
def test_the_live_script_runs(self):
for apply in (None, [{"index": 1, "name": "x", "send": 1.0}]):
compile(rd.LIVE_SCRIPT % json.dumps({"send_index": 0, "apply": apply}), "remote", "exec")
def test_the_hold_reads_then_sets(self):
calls = []
def fake(cfg, rc, apply=None):
calls.append(apply)
return live_set() if apply is None else {"done": [c["name"] for c in apply], "skipped": []}
real, rd.live_look = rd.live_look, fake
try:
self.assertTrue(rd.run_action(CONFIG, "ableton_mix"))
finally:
rd.live_look = real
self.assertEqual([None if c is None else len(c) for c in calls], [None, 3])
def test_osc_hand_back_message(self):
self.assertEqual(rd.osc_message("/live/song/get/tempo"), b"/live/song/get/tempo\0\0\0\0,\0\0\0")
def with_tune(al=None):
al = al or ableton()
al["summary"]["tune"] = [[0, 1, "Bbmaj", 2], [100, 101, "Ebmaj", 7], [200, 201, "Bmaj", 3], [300, 301, "Emaj", 8]]
al["tune_library"] = {"Bbmaj": 2, "Bmaj": 3, "Ebmaj": 7, "Emaj": 8, "OFF | Chromatic": 0, "LOOP CUE": None}
return al
class TestWavesTune(unittest.TestCase):
def test_clip_names(self):
self.assertEqual([rd.tune_key(n) for n in ("Ebmaj", "C#min", "Bmaj", "OFF | Chromatic", "LOOP CUE")],
["Eb", "C#m", "B", None, None])
self.assertTrue(rd.same_tune("G", "Em"))
self.assertFalse(rd.same_tune("E", "Em"))
def test_each_song_on_its_key(self):
line = rd.eval_tune(RC, keyed(GOOD), with_tune())
# Covered By The Blood is in B, but its song starts on the Emaj clip
self.assertEqual(msgs(line), ["COVERED BY TH. TUNE E"])
self.assertIn("put the Bmaj clip there instead", line.as_dict()["details"][0])
self.assertEqual(line.info, "3 SONGS IN KEY")
def test_a_renamed_clip_sends_the_wrong_program(self):
al = with_tune()
al["summary"]["tune"][2][3] = 8 # named Bmaj, sends Emaj's program
self.assertIn("HOLY FOREVER TUNE PGM", msgs(rd.eval_tune(RC, keyed(GOOD), al)))
def test_no_clips(self):
self.assertEqual(msgs(rd.eval_tune(RC, keyed(GOOD), ableton())), ["NO TUNE CLIPS"])
class TestTimecode(unittest.TestCase): class TestTimecode(unittest.TestCase):
def test_seconds_through_the_tempo_map(self): def test_seconds_through_the_tempo_map(self):
tempo = ableton()["summary"]["tempo"] tempo = ableton()["summary"]["tempo"]
@@ -361,7 +539,7 @@ class TestLook(unittest.TestCase):
src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(), src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(),
lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]), lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]),
resolume=composition, prop_bundle=lambda plan: [["Message Templates.probundle", WEDNESDAY]], resolume=composition, prop_bundle=lambda plan: [["Message Templates.probundle", WEDNESDAY]],
macros=macros_found, companion=lambda n: COMPANION.get(n, {})) macros=macros_found, companion=lambda n: COMPANION.get(n, {}), live=live_set)
src.update(over) src.update(over)
return src return src
@@ -389,7 +567,7 @@ class TestLook(unittest.TestCase):
cfg["ready"].pop(k) cfg["ready"].pop(k)
cfg["ready"]["prop_url"] = "" cfg["ready"]["prop_url"] = ""
r = rd.look(cfg, now=WEDNESDAY, sources=self.sources()) r = rd.look(cfg, now=WEDNESDAY, sources=self.sources())
self.assertEqual(sorted(r["items"]), ["lights", "mics", "mix_foh", "timecode", "tracks"]) # LTC: tracks vs PCO self.assertEqual(sorted(r["items"]), ["ears", "keys", "lights", "mics", "mix_foh", "routing", "timecode", "tracks", "tune"]) # LTC: tracks vs PCO
engine, snap = sp.Engine(cfg, None), sp.Snapshot() engine, snap = sp.Engine(cfg, None), sp.Snapshot()
snap.ready = r snap.ready = r
tiles = engine.step(snap, WEDNESDAY) tiles = engine.step(snap, WEDNESDAY)
@@ -445,7 +623,7 @@ class TestWatchdog(unittest.TestCase):
self.step(WEDNESDAY) self.step(WEDNESDAY)
plan = self.engine.fix_plan("ready", self.snap) plan = self.engine.fix_plan("ready", self.snap)
fixes = sorted(what for _, kind, what, _ in plan if kind == "fix") fixes = sorted(what for _, kind, what, _ in plan if kind == "fix")
self.assertEqual(fixes, ["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"])
ran = [] ran = []
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
self.engine.run_fixes("ready", self.snap, None, WEDNESDAY + 10) self.engine.run_fixes("ready", self.snap, None, WEDNESDAY + 10)