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:
@@ -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"`:
|
||||
|
||||
+274
-2
@@ -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",
|
||||
|
||||
+430
-7
@@ -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:
|
||||
|
||||
Binary file not shown.
@@ -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 "
|
||||
"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.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.remote_timeout", "int", 90, "Seconds allowed for reading the Ableton set.", "Ready", advanced=True,
|
||||
lo=10, hi=600)
|
||||
|
||||
@@ -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.",
|
||||
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.",
|
||||
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.",
|
||||
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.",
|
||||
|
||||
+2
-2
@@ -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.
|
||||
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")
|
||||
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 = {
|
||||
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
|
||||
|
||||
+181
-3
@@ -256,6 +256,184 @@ class TestAbleton(unittest.TestCase):
|
||||
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):
|
||||
def test_seconds_through_the_tempo_map(self):
|
||||
tempo = ableton()["summary"]["tempo"]
|
||||
@@ -361,7 +539,7 @@ class TestLook(unittest.TestCase):
|
||||
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"]),
|
||||
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)
|
||||
return src
|
||||
|
||||
@@ -389,7 +567,7 @@ class TestLook(unittest.TestCase):
|
||||
cfg["ready"].pop(k)
|
||||
cfg["ready"]["prop_url"] = ""
|
||||
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()
|
||||
snap.ready = r
|
||||
tiles = engine.step(snap, WEDNESDAY)
|
||||
@@ -445,7 +623,7 @@ class TestWatchdog(unittest.TestCase):
|
||||
self.step(WEDNESDAY)
|
||||
plan = self.engine.fix_plan("ready", self.snap)
|
||||
fixes = sorted(what for _, kind, what, _ in plan if kind == "fix")
|
||||
self.assertEqual(fixes, ["ready_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 = []
|
||||
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
|
||||
self.engine.run_fixes("ready", self.snap, None, WEDNESDAY + 10)
|
||||
|
||||
Reference in New Issue
Block a user