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