READY: START builds the week's Ableton sets (Sunday and special services) from Planning Center

Hold START on Monday: last weekend's songs go to Finished Songs (a changed one parks the version it
came from as z...), then each service PCO lists this week (weekend + Special Services / Morning
Worship...; First / Together Tuesday in Projects/FIRST TUESDAY) gets 'YYMMDD NA Project' built from
the newest weekend set: locators, tempo map and TEMPO clips, Waves Tune cues, songs from Finished
Songs (transposed Complex Pro when only another key exists), markers / guide / lighting cues on the
dedicated tracks, pads, routing and send A at 0 dB, audio copied in with an Ableton Project Info folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 12:38:09 -04:00
co-authored by Claude Opus 5.5
parent e2add3b27e
commit 0d655bff3c
10 changed files with 2027 additions and 7 deletions
+42 -3
View File
@@ -131,7 +131,7 @@ HyperDeck is recording). **STATUS PAGE** (bottom left) goes back.
| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE | | row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE |
| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens in 2.5 ms steps out to almost a 16th either way, finds the strongest hit and where it starts (halfway up its rise); what is under the cue is looked at from 10 ms before it to 25 ms after (the stems' hits start a little after the grid). Each song's cues that sit on solid hits set what "on time" is for that song (its stem's hits start 7-12 ms after the grid). **14 CUES OFF** (amber): a strong hit more than 25 ms from the cue while the cue sits on a quiet spot; a cue already on a solid hit (a double hit follows) is never named. The details say `JEHOVAH bar 405.3.2: Blinder Flash 2 is 145 ms after the hit (about a 16th)` (bars as Live shows them, 4/4). Listened to once per saved set. | moves every cue that is off onto its hit in the open set (all flash/FX notes on that 16th; onto the 16th grid when the hit is within 10 ms of it), keeping where each cue ends, then saves the set (File › Save Live Set in Live, clicked through System Events: `/usr/bin/osascript` has Accessibility on the Tracks Mac since 2026-10-22; `ready.light_autosave`), but only when the set open in Live is this weekend's. If it cannot save, the key says **SAVE SET (n MOVED)** until the set is saved; Cmd+Z in Live takes a move back. Never runs by itself. Uses the status panel's command in AbletonOSC (`tools/abletonosc`: `statuspanel.py` plus a hook at the end of AbletonOSC's `abletonosc/__init__.py`; an AbletonOSC update removes it, so run `tools/abletonosc/install.sh` again, then restart Companion's `ableton` connection) | | row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens in 2.5 ms steps out to almost a 16th either way, finds the strongest hit and where it starts (halfway up its rise); what is under the cue is looked at from 10 ms before it to 25 ms after (the stems' hits start a little after the grid). Each song's cues that sit on solid hits set what "on time" is for that song (its stem's hits start 7-12 ms after the grid). **14 CUES OFF** (amber): a strong hit more than 25 ms from the cue while the cue sits on a quiet spot; a cue already on a solid hit (a double hit follows) is never named. The details say `JEHOVAH bar 405.3.2: Blinder Flash 2 is 145 ms after the hit (about a 16th)` (bars as Live shows them, 4/4). Listened to once per saved set. | moves every cue that is off onto its hit in the open set (all flash/FX notes on that 16th; onto the 16th grid when the hit is within 10 ms of it), keeping where each cue ends, then saves the set (File › Save Live Set in Live, clicked through System Events: `/usr/bin/osascript` has Accessibility on the Tracks Mac since 2026-10-22; `ready.light_autosave`), but only when the set open in Live is this weekend's. If it cannot save, the key says **SAVE SET (n MOVED)** until the set is saved; Cmd+Z in Live takes a move back. Never runs by itself. Uses the status panel's command in AbletonOSC (`tools/abletonosc`: `statuspanel.py` plus a hook at the end of AbletonOSC's `abletonosc/__init__.py`; an AbletonOSC update removes it, so run `tools/abletonosc/install.sh` again, then restart Companion's `ableton` connection) |
| row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | | | row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | |
| row 3 (outgoing) | TRACKS OUT | PROP OUT | a `.probundle` is in this weekend's folder of the Chronicles share (`/Volumes/Location Assets/261025`, read on the ProPresenter Mac). **SHARE NOT MOUNTED**: the share itself is not mounted there (2026-10-22: it had dropped off and the key said NOT IN 261025 although the bundle was there). The ProP Mac keeps it mounted by itself: a LaunchAgent (`org.waterschurch.keep-mounted.location-assets`, from `remote-access/mac-keep-mounted.sh`) looks every minute and at login, mounts it again with the Chronicles login saved in the Keychain, and unmounts and remounts it when it hangs after sleep or a network drop (log: `~/Library/Logs/keep-mounted-location-assets.log` on the ProP Mac). | starts that agent now and waits for the share (about 6 s), then looks again. Also an auto-fix. | | row 3 (outgoing) | TRACKS OUT | PROP OUT | START | | | | |
The READY key at row 3 col 7 (and the one on the status page) shows how many keys still have something to do and The READY key at row 3 col 7 (and the one on the status page) shows how many keys still have something to do and
the first of them. the first of them.
@@ -163,7 +163,8 @@ everything on the page at once.
| LED CROSS | every Resolume column Sunday's playlist calls up has content: each slide's macro (read from the ProPresenter Mac's `Macros` and `.pro` files) sends RossTalk `CC page/row/col` to Companion, that button (and the buttons it presses, e.g. page 17 → page 6 "Arena") sends `/composition/columns/N/connect`; **COL 11 EMPTY** names the column and the items that use it. Also: Resolume on the **Service** deck. | selects the Service deck, then looks again (an empty column is filled in Resolume) | | LED CROSS | every Resolume column Sunday's playlist calls up has content: each slide's macro (read from the ProPresenter Mac's `Macros` and `.pro` files) sends RossTalk `CC page/row/col` to Companion, that button (and the buttons it presses, e.g. page 17 → page 6 "Arena") sends `/composition/columns/N/connect`; **COL 11 EMPTY** names the column and the items that use it. Also: Resolume on the **Service** deck. | selects the Service deck, then looks again (an empty column is filled in Resolume) |
| LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center | | LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center |
| TRACKS OUT | every location in `"locations"` (AB, SC) has this weekend's project folder | copies NA's project to the locations that have none (as `261018 AB Project`, without `Backup/`). It is copied under a temporary name and renamed at the end, so a half-finished copy never looks sent. It never overwrites a folder that is already there and never runs by itself. | | TRACKS OUT | every location in `"locations"` (AB, SC) has this weekend's project folder | copies NA's project to the locations that have none (as `261018 AB Project`, without `Backup/`). It is copied under a temporary name and renamed at the end, so a half-finished copy never looks sent. It never overwrites a folder that is already there and never runs by itself. |
| PROP OUT | a `.probundle` was saved to `Location Assets/ProPresenter` since Monday | looks again | | PROP OUT | a `.probundle` is in this weekend's folder of the Chronicles share (`/Volumes/Location Assets/261025`, read on the ProPresenter Mac). **SHARE NOT MOUNTED**: the share itself is not mounted there (2026-10-22: it had dropped off and the key said NOT IN 261025 although the bundle was there). The ProP Mac keeps it mounted by itself: a LaunchAgent (`org.waterschurch.keep-mounted.location-assets`, from `remote-access/mac-keep-mounted.sh`) looks every minute and at login, mounts it again with the Chronicles login saved in the Keychain, and unmounts and remounts it when it hangs after sleep or a network drop (log: `~/Library/Logs/keep-mounted-location-assets.log` on the ProP Mac). | starts that agent now and waits for the share (about 6 s), then looks again. Also an auto-fix. |
| START | every service Planning Center lists this week (this location's Sunday and every special service: First / Together Tuesday, Morning Worship, ...) has its Ableton set. **NO SET SUN 11/1** (red when due): hold START. While it builds the key shows what it is doing (**SAVING JEHOVAH**, **COPYING 40/120**). After a build, for a day: **CONSOLIDATE THE LORD IS** (a song transposed to its PCO key: consolidate it in Live), **NEW SONG GRACE** (not in Finished Songs: build it by hand, page 3 of the workflow PDF). See *START* below. | builds the missing sets (on the Tracks Mac, in the background). Never runs by itself. |
**Where the answers come from:** **Where the answers come from:**
- **Planning Center:** ProdDash's own login on this Mac (`~/Library/Application Support/ProdDash/modules.json`, module `pco-plan`). The plan is today's, else the next one, which is the same plan the lineup TV picks. - **Planning Center:** ProdDash's own login on this Mac (`~/Library/Application Support/ProdDash/modules.json`, module `pco-plan`). The plan is today's, else the next one, which is the same plan the lineup TV picks.
@@ -175,9 +176,47 @@ everything on the page at once.
listens for its answers on NA Ingest, so straight after each read NA Ingest asks it for the tempo and it answers listens for its answers on NA Ingest, so straight after each read NA Ingest asks it for the tempo and it answers
Companion again. Companion again.
Nothing on the READY page writes to Companion, ProPresenter or the Ableton set file. ROUTING and IN EARS' fix changes Nothing on the READY page writes to Companion, ProPresenter or an Ableton set file (START only writes new sets and Finished Songs folders). 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. only the open set in Live (outputs and send A), and only tracks whose name is still where it was a moment before.
### START: the week's Ableton sets (Monday)
Hold **START** (READY page, row 3 col 3) on Monday. `readiness.py` asks Planning Center for every service from today
until the Thursday after Sunday: NA's weekend service type (`pco_service_type`) and the service types next to it in
PCO (`ready.start_types`: Special Services, Special Events, Morning Worship, Forge & Flourish; not youth, kids or
classes, `ready.start_skip_types`). A plan counts when it has songs with a key. On a Monday that is Sunday and, when
there is one, First / Together Tuesday. Each set is named the year-month-day way plus the location: Sunday 1 Nov 2026
is `Projects/NA/261101 NA Project`, a First Tuesday is `Projects/FIRST TUESDAY/261103 NA First Tuesday Project`, any
other special service `Projects/NA/<date> NA <plan title> Project`.
`ableton_start.py` then runs on the Tracks Mac (detached, at low priority), doing the workflow PDF's steps:
1. **Last weekend's songs to Finished Songs.** Each song group of the newest weekend set goes to
`Finished Songs/TITLE [KEY]` (key from its locator), with its arrangement markers, lighting cues and guide inside
the group and its audio copied in. A song that matches a saved version (any key, parked or not) is left alone.
A changed one replaces the version it was made from (`CAN'T HELP BUT PRAISE LM [Eb]` for a group called
`CAN'T HELP BUT PRAISE`), which is parked first as `zCAN'T HELP BUT PRAISE LM [Eb]`.
2. **A new set from the newest weekend set:** its songs, pads, Waves Tune cues and marker, guide, loop-guide and
lighting clips go; the groups, the numbered locators (and their computer keys 1-9), the loop locators and the loop
cues stay. Empty loop cues are dropped.
3. **Locators** `N- TITLE - KEY` from PCO: 1 Call to Worship (first song's key and tempo), the songs, then Salvation
(the song after the Salvation moment, a TAG, else the last song). Fewer songs: Salvation moves up a slot (as
"5- SALVATION" did with 3 songs); more: slots are added like the last one. A song too long for its slot pushes the
loop locators, loop cues and later slots along in whole bars, so a loop never fires inside a song.
4. **Tempo map** and one **TEMPO** label clip per tempo (`128.00 BPM`). The tempo is the one the song's audio was
cut at; a different PCO tempo is named in the log.
5. **Waves Tune** key cue at every locator (minor keys use their relative major).
6. **Each song from Finished Songs** at its locator, in its PCO key if that version exists, else the nearest key
transposed the PDF's way (warp on, Complex Pro, formants 0, transpose) and named on the key, because consolidating
is done in Live. Its markers, guide and lighting cues go onto `SECTIONS`, `+GUIDE` and `LIGHTING CUES`.
7. **Pads**: the Call to Worship pad in the first song's key, an ending pad per song in its key, run past its loop,
Salvation's.
8. **Routing** per `ready.routing` and **send A at 0 dB** on every stem.
Every audio file is copied into the new project (as Collect All and Save does) with an `Ableton Project Info` folder
(Live finds no audio without it: 2026-10-22). A set that is already there is never touched; a new one is built as
`<name>.building` and renamed when complete. Progress: `~/Library/Caches/status-panel-start.json` on the Tracks Mac.
START is on where this location sends its tracks to others (`locations`), or with `ready.start` set.
**Another location:** copy `"ready_page"`, `"ready"`, the four `ready_*` fixes and the READY tile from this config, **Another location:** copy `"ready_page"`, `"ready"`, the four `ready_*` fixes and the READY tile from this config,
then change these in `"ready"`: then change these in `"ready"`:
- `pco_service_type` - `pco_service_type`
+1352
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -517,6 +517,13 @@
"kind": "ready", "kind": "ready",
"short": "MOUNT", "short": "MOUNT",
"label": "mount Location Assets again on the ProPresenter Mac (its keep-mounted agent), then look for the bundle again" "label": "mount Location Assets again on the ProPresenter Mac (its keep-mounted agent), then look for the bundle again"
},
"ready_start": {
"action": "start_week",
"kind": "ready",
"label": "build this week's Ableton sets (Sunday and special services) from Planning Center",
"never_auto": true,
"short": "START"
} }
}, },
"flap": { "flap": {
@@ -1498,6 +1505,21 @@
3, 3,
7 7
] ]
},
{
"id": "rd_start",
"label": "START",
"pos": [
3,
3
],
"checks": [
{
"type": "ready",
"item": "start",
"fix": "ready_start"
}
]
} }
], ],
"page": 21, "page": 21,
+204 -1
View File
@@ -30,6 +30,8 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p
mics (a tap-to-confirm key) which mics this weekend needs, from PCO mics (a tap-to-confirm key) which mics this weekend needs, from PCO
track_bundle every location has this weekend's Ableton project folder on the shared drive track_bundle every location has this weekend's Ableton project folder on the shared drive
prop_bundle a ProPresenter bundle was saved to the locations' folder this week prop_bundle a ProPresenter bundle was saved to the locations' folder this week
start every service PCO lists this week (Sunday and special services such as First / Together
Tuesday) has its Ableton set; the hold builds the missing ones (ableton_start.py)
What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync
the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that
@@ -1747,7 +1749,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6), resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6),
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60), macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc), companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc),
hits=lambda al: light_hits(cfg, rc, al), hits=lambda al: light_hits(cfg, rc, al), services=lambda: upcoming_services(rc, now),
start=lambda svcs: start_look(cfg, rc, svcs),
prop_bundle=lambda plan: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps( prop_bundle=lambda plan: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps(
{"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30)) {"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
src.update(sources or {}) src.update(sources or {})
@@ -1772,6 +1775,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")), ("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
("lineup", rc.get("lineup_url")), ("mics", True), ("lineup", rc.get("lineup_url")), ("mics", True),
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")), ("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")),
("start", start_on(rc)),
("led_cross", rc.get("resolume_url") and rc.get("prop_host"))) if on], e) ("led_cross", rc.get("resolume_url") and rc.get("prop_host"))) if on], e)
if plan is not None: if plan is not None:
prop = None prop = None
@@ -1846,6 +1850,12 @@ def look(cfg, now=None, data_dir=None, sources=None):
lines["lineup"] = eval_lineup(rc, plan, src["lineup"]()) lines["lineup"] = eval_lineup(rc, plan, src["lineup"]())
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
failed(["lineup"], e if isinstance(e, ReadyError) else ReadyError("LINEUP UNREACHABLE")) failed(["lineup"], e if isinstance(e, ReadyError) else ReadyError("LINEUP UNREACHABLE"))
if start_on(rc):
try:
services = src["services"]()
lines["start"] = eval_start(rc, services, src["start"](services), now)
except Exception as e: # noqa: BLE001
failed(["start"], e)
mics = Line() mics = Line()
mics.info = mics_needed(rc, plan) mics.info = mics_needed(rc, plan)
lines["mics"] = mics lines["mics"] = mics
@@ -1869,6 +1879,7 @@ class Poller(threading.Thread):
super().__init__(name="ready", daemon=True) super().__init__(name="ready", daemon=True)
self.cfg, self.snap, self.stop, self.data_dir, self.is_live = cfg, snap, stop, data_dir, is_live self.cfg, self.snap, self.stop, self.data_dir, self.is_live = cfg, snap, stop, data_dir, is_live
self.interval = (cfg.get("ready") or {}).get("interval_seconds", 120) self.interval = (cfg.get("ready") or {}).get("interval_seconds", 120)
IS_LIVE[0] = is_live
def run(self): def run(self):
forced = True forced = True
@@ -1891,6 +1902,187 @@ class Poller(threading.Thread):
break break
# --- START: the week's Ableton sets (ableton_start.py, run on the Tracks Mac) ----------------------------
# The services come from PCO: this location's weekend service type, and every special one beside it (same
# parent folder in PCO: "Special Services" holds First / Together Tuesday, "Morning Worship", "Forge &
# Flourish"...), skipping youth, kids and classes. A plan counts when it has songs with a key.
START_SOURCE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ableton_start.py")
START_TYPES = r"services|events|worship|forge|flourish"
START_SKIP = (r"youth|yth|kids|young adult|growth|baptism|guest|security|caf|preschool|nursery|toddler|elementary|"
r"academy|homeschool|audition|connect")
SERVICES = {} # the services START builds, kept a quarter of an hour (not asked of PCO every look)
IS_LIVE = [lambda: False] # the Poller's "a service is on": the hold copies gigabytes, never during one
START_SURVEY = r'''
import json, os
P = json.loads(%r)
out = {}
for s in P["services"]:
d = os.path.join(os.path.expanduser(P["projects"]), s["folder"], s["name"])
out[s["name"]] = {"exists": os.path.isdir(d) and any(n.endswith(".als") for n in os.listdir(d)),
"building": os.path.isdir(d + ".building")}
try:
st = json.load(open(os.path.expanduser("~/Library/Caches/status-panel-start.json")))
except Exception:
st = None
print(json.dumps({"survey": out, "status": st}))
'''
def start_on(rc):
"""START is where the sets are made: the location that sends its tracks to the others (or one set to)."""
on = rc.get("start")
return bool(rc.get("tracks_host") and rc.get("projects") and (on if on is not None else rc.get("locations")))
def start_window(rc, now):
"""Today up to the Thursday after this week's Sunday: on Monday that is Sunday and a First Tuesday."""
today = datetime.fromtimestamp(now).replace(hour=0, minute=0, second=0, microsecond=0)
return time.mktime(today.timetuple()), week_start(now, rc.get("reset_day", "mon")) + 10 * 86400
def plan_service(auth, st, plan_id, rc):
"""(songs, salvation) of a plan: the band's songs with their PCO key and tempo, in order; Salvation is the
song after the Salvation moment (a TAG) if there is one, else the last song (as the sets do it)."""
items, inc = pco_get(auth, "/service_types/%s/plans/%s/items?include=arrangement&per_page=100" % (st, plan_id))
bpm = {i["id"]: (i.get("attributes") or {}).get("bpm") for i in inc if i.get("type") == "Arrangement"}
skip = {norm(x) for x in rc.get("skip_songs", [])}
songs, sal, past = [], None, False
for it in items:
a = it.get("attributes") or {}
title = (a.get("title") or "").strip()
if re.search(r"salvation", title, re.I):
past = True
continue
if a.get("item_type") != "song" or a.get("service_position") not in ("during", "") or not a.get("key_name"):
continue
if norm(title) in skip:
continue
ref = ((it.get("relationships") or {}).get("arrangement") or {}).get("data") or {}
song = {"title": title, "key": a["key_name"], "bpm": bpm.get(ref.get("id"))}
if past or re.search(r"\bTAG\b", title, re.I):
sal = sal or song
continue
if not any(same(song["title"], x["title"]) and same_key(song["key"], x["key"]) for x in songs):
songs.append(song)
return songs, sal or (songs[-1] if songs else None)
def service_entry(rc, weekend, type_name, plan, t, songs, sal):
"""Where a service's set goes: "NA/261101 NA Project", "FIRST TUESDAY/261103 NA First Tuesday Project"."""
d = datetime.fromtimestamp(t)
date, loc = d.strftime("%y%m%d"), rc.get("site_folder", "")
fmt = rc.get("folder_format", "{date} {loc} Project")
a = plan.get("attributes") or {}
title = " ".join(re.sub(r"[/:\\]", "-", (a.get("title") or type_name.split("|")[-1]).strip()).split())
if weekend:
folder, name = loc, fmt.format(date=date, loc=loc)
else:
tues = re.search(r"tuesday", title + " " + type_name, re.I)
folder = rc.get("start_tuesday_folder", "FIRST TUESDAY") if tues else loc
name = fmt.format(date=date, loc="%s %s" % (loc, title))
label = "%s %d/%d" % (d.strftime("%a").upper(), d.month, d.day)
return {"date": date, "folder": folder, "name": name, "label": label, "title": title or type_name,
"songs": songs, "salvation": sal, "service_type": plan.get("_st"), "plan_id": plan.get("id")}
def upcoming_services(rc, now, fresh=False):
"""The services START builds sets for, soonest first."""
lo, hi = start_window(rc, now)
key = (lo, int(now // 900))
if not fresh and SERVICES.get("key") == key:
return SERVICES["list"]
auth, home = pco_login(rc)
try:
types, _ = pco_get(auth, "/service_types?per_page=100", pages=3)
except OSError as e:
raise ReadyError("PCO UNREACHABLE") from e
by_id = {x["id"]: x for x in types}
parent_of = lambda x: ((((x or {}).get("relationships") or {}).get("parent") or {}).get("data") or {}).get("id")
name_of = lambda x: ((x or {}).get("attributes") or {}).get("name") or ""
want = re.compile(rc.get("start_types", START_TYPES), re.I)
skip = re.compile(rc.get("start_skip_types", START_SKIP), re.I)
chosen = [home] + [x["id"] for x in types if x["id"] != home and parent_of(x) and parent_of(x) == parent_of(by_id.get(home))
and want.search(name_of(x)) and not skip.search(name_of(x))]
chosen += [str(x) for x in rc.get("start_service_types", []) if str(x) not in chosen]
out = []
for st in chosen:
try:
plans, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1)
except OSError as e:
raise ReadyError("PCO UNREACHABLE") from e
for p in plans:
t = parse_iso((p.get("attributes") or {}).get("sort_date"))
if t is None or not lo <= t < hi:
continue
songs, sal = plan_service(auth, st, p["id"], rc)
if songs:
out.append(service_entry(rc, st == home, name_of(by_id.get(st)), dict(p, _st=st), t, songs, sal))
out.sort(key=lambda x: (x["date"], x["name"]))
SERVICES.update(key=key, list=out)
return out
def start_params(rc, services, mode):
projects = rc.get("projects", "")
return {"mode": mode, "projects": projects, "site_folder": rc.get("site_folder", ""),
"folder_format": rc.get("folder_format", "{date} {loc} Project"), "finished_folders": rc.get("finished_folders", []),
"pads_folder": rc.get("pads_folder") or os.path.join(os.path.dirname(projects.rstrip("/")), "PADS"),
"tune_library": rc.get("tune_library", ""), "routing": rc.get("routing") or {},
"keep_groups": rc.get("start_keep_groups") or rc.get("ears_skip_groups") or ["MIDI GROUP", "RESI TRACKS"],
"pad_track": rc.get("pad_track", "AMBIENT PADS"), "tune_track": rc.get("tune_track", "WAVES TUNE"),
"lighting_track": rc.get("lighting_track", "LIGHTING CUES"), "sections_track": rc.get("sections_track", "SECTIONS"),
"guide_track": rc.get("guide_track", "+GUIDE"), "loopguide_track": rc.get("loopguide_track", "+LOOPGUIDE"),
"loop_cue_track": rc.get("loop_cue_track", "Loop Cue"), "tempo_track": rc.get("tempo_track", "TEMPO"),
"send_a": 1.0, "save_songs": rc.get("start_save_songs", True), "services": services}
def start_script(params):
"""ableton_start.py with its job; it keeps its own source, to start itself detached on the Tracks Mac."""
with open(START_SOURCE) as f:
src = f.read()
return "SOURCE = %r\n%s\nmain(json.loads(%r))\n" % (src, src, json.dumps(params))
def start_look(cfg, rc, services):
return run_remote(cfg, rc["tracks_host"], START_SURVEY % json.dumps({"projects": rc.get("projects", ""), "services": [
{"folder": x["folder"], "name": x["name"]} for x in services]}), timeout=30)
def eval_start(rc, services, look, now):
"""START: every service this week has its set (built, or being built); what the last build left to do."""
line = Line()
st = look.get("status") or {}
survey = look.get("survey") or {}
running = st.get("state") == "running" and now - (st.get("time") or 0) < 1800
if running:
line.fault(WARN, (st.get("step") or "BUILDING")[:24], "START is building: %s" % st.get("step"))
elif st.get("state") == "failed" and now - (st.get("time") or 0) < 7 * 86400:
line.fault(ERR, "START FAILED", "the last build stopped: %s" % st.get("step"))
for svc in services:
got = survey.get(svc["name"]) or {}
if not got.get("exists") and not running:
line.fault(ERR, "NO SET %s" % svc["label"], "%s has no Ableton set yet (%s/%s): hold START to build it" % (
svc["title"] or svc["label"], svc["folder"], svc["name"]))
if st.get("state") == "done" and now - (st.get("time") or 0) < 86400:
names = {x["name"] for x in services if (survey.get(x["name"]) or {}).get("exists")}
for res in st.get("results") or []:
if res.get("name") not in names:
continue
for note in res.get("notes") or []:
m = re.match(r"TRANSPOSE (.+?) \S+>\S+", note)
n = re.match(r"NEW SONG (.+?):", note)
if m:
line.fault(WARN, "CONSOLIDATE " + short(m.group(1), 12), "%s: %s" % (res["name"], note))
elif n:
line.fault(WARN, "NEW SONG " + short(n.group(1), 12), "%s: %s" % (res["name"], note))
else:
line.notes.append("%s: %s" % (res["name"], note))
line.info = " ".join(x["label"] for x in services) or "NO SERVICES"
return line
# --- Fixes (a hold on a READY key: fix kind "ready") ------------------------------------------------ # --- Fixes (a hold on a READY key: fix kind "ready") ------------------------------------------------
def write_ltc(rc, songs, service_type, plan_id): def write_ltc(rc, songs, service_type, plan_id):
@@ -1978,6 +2170,17 @@ def run_action(cfg, action, chk=None, sleep=time.sleep):
return "NO AGENT" if r.get("no_agent") else "STILL NOT MOUNTED" return "NO AGENT" if r.get("no_agent") else "STILL NOT MOUNTED"
if action == "light_timing": if action == "light_timing":
return fix_light_timing(cfg, rc) return fix_light_timing(cfg, rc)
if action == "start_week":
if IS_LIVE[0]():
return "SERVICE ON"
services = upcoming_services(rc, time.time(), fresh=True)
if not services:
return "NO SERVICES IN PCO"
r = run_remote(cfg, rc["tracks_host"], start_script(start_params(rc, services, "run")), timeout=60)
if r.get("busy"):
return "ALREADY BUILDING"
log.info("FIX START: building the sets for %s", ", ".join("%s (%s)" % (x["label"], x["name"]) for x in services))
return True if r.get("started") else "DID NOT START"
if action == "ableton_mix": if action == "ableton_mix":
_, _, changes = mix_plan(rc, live_look(cfg, rc)) _, _, changes = mix_plan(rc, live_look(cfg, rc))
if not changes: if not changes:
Binary file not shown.
+22
View File
@@ -425,6 +425,26 @@ field("ready.finished_folders", "list", [], "Folders of finished songs named \"S
"them: each song must have a version in the key Planning Center gives it.", "Ready", "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"]) example=["~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/Finished Songs"])
field("ready.locations", "list", [], "The locations that get this location's tracks.", "Ready") field("ready.locations", "list", [], "The locations that get this location's tracks.", "Ready")
field("ready.start", "bool", None, "START: the week's Ableton sets (on by itself where this location sends its tracks to others). Every service Planning Center lists this week (this "
"location's weekend service and the special ones beside it: First / Together Tuesday, Morning Worship...) must have "
"its set; a hold builds the missing ones from the newest weekend set and Finished Songs.", "Ready")
field("ready.start_types", "str", r"services|events|worship|forge|flourish", "PCO service types (next to the weekend one) "
"whose plans with songs get a set.", "Ready", advanced=True)
field("ready.start_skip_types", "str", r"youth|yth|kids|young adult|growth|baptism|guest|security|caf|preschool|nursery|"
r"toddler|elementary|academy|homeschool|audition|connect", "PCO service types START leaves alone.", "Ready", advanced=True)
field("ready.start_service_types", "list", [], "More PCO service type ids START builds sets for.", "Ready", advanced=True)
field("ready.start_tuesday_folder", "str", "FIRST TUESDAY", "The Projects folder for First / Together Tuesday sets.", "Ready")
field("ready.start_save_songs", "bool", True, "START saves the previous weekend's songs to Finished Songs first "
"(a changed song parks the old folder as zSONG [KEY]).", "Ready")
field("ready.start_keep_groups", "list", [], "Groups START keeps when it clears last week's songs (empty: the in-ear skip "
"groups, MIDI GROUP and RESI TRACKS).", "Ready", advanced=True)
field("ready.pads_folder", "path", "", "The ambient pad files (\"D# - WARM - CHURCHFRONT PADS.wav\"); empty: PADS next to "
"Projects.", "Ready", advanced=True)
field("ready.sections_track", "str", "SECTIONS", "The arrangement marker track.", "Ready", advanced=True)
field("ready.guide_track", "str", "+GUIDE", "The guide track.", "Ready", advanced=True)
field("ready.loopguide_track", "str", "+LOOPGUIDE", "The loop guide track (cleared each week).", "Ready", advanced=True)
field("ready.loop_cue_track", "str", "Loop Cue", "The MIDI track whose clips trigger the loops.", "Ready", advanced=True)
field("ready.tempo_track", "str", "TEMPO", "The track whose clips name each song's tempo.", "Ready", advanced=True)
field("ready.remote_timeout", "int", 90, "Seconds allowed for reading the Ableton set.", "Ready", advanced=True, field("ready.remote_timeout", "int", 90, "Seconds allowed for reading the Ableton set.", "Ready", advanced=True,
lo=10, hi=600) lo=10, hi=600)
field("ready.lineup_url", "url", "", "ProdDash's Band Lineup module.", "Ready") field("ready.lineup_url", "url", "", "ProdDash's Band Lineup module.", "Ready")
@@ -526,6 +546,8 @@ GLOSSARY = {
"NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN", "NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN",
"PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE", "PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE",
"UNREACHABLE", "CHECK FAILED", "TURNED OFF", "COL", "COLUMNS OK", "NO COLUMNS USED", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX", "UNREACHABLE", "CHECK FAILED", "TURNED OFF", "COL", "COLUMNS OK", "NO COLUMNS USED", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX",
"START", "START FAILED", "BUILDING", "SAVING", "COPYING", "CONSOLIDATE", "NEW SONG", "NO SERVICES",
"NO SERVICES IN PCO", "SERVICE ON", "ALREADY BUILDING", "DID NOT START", "NOTHING TO DO",
], ],
"ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"], "ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"],
"what a key says while you hold it": [ "what a key says while you hold it": [
+1
View File
@@ -595,6 +595,7 @@
lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.", lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.",
track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.", track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.",
prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.", prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.",
start: "Every service Planning Center lists this week (Sunday and any special service: First / Together Tuesday...) has its Ableton set. Hold START on Monday: it saves last weekend's songs to Finished Songs, then builds each set from the newest weekend set (locators, tempos, Waves Tune cues, songs, markers, guide, lighting cues, pads, routing, in-ear sends). A song in another key is transposed and named here, to consolidate in Live; a song not in Finished Songs is named, to build by hand.",
}; };
async function readyKeyPanel(i, hold) { async function readyKeyPanel(i, hold) {
const c = cfg(); const c = cfg();
+3 -2
View File
@@ -818,8 +818,9 @@ def check_ready_manual(chk, snap, ctx):
# What readiness.py looks at (check type "ready") and what a "ready" fix can do. # What readiness.py looks at (check type "ready") and what a "ready" fix can do.
READY_ITEMS = ("tracks", "mix_foh", "lights", "light_timing", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics", READY_ITEMS = ("tracks", "mix_foh", "lights", "light_timing", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics",
"track_bundle", "prop_bundle") "track_bundle", "prop_bundle", "start")
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix", "light_timing", "mount_share") READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix", "light_timing", "mount_share",
"start_week")
CHECKS = { CHECKS = {
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure, "conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
+4 -1
View File
@@ -26,6 +26,8 @@ RC = CONFIG["ready"]
WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber
SATURDAY = datetime(2026, 10, 17, 16, 0).timestamp() # inside "due": gaps are red SATURDAY = datetime(2026, 10, 17, 16, 0).timestamp() # inside "due": gaps are red
SUNDAY_PLAN = datetime(2026, 10, 18, 9, 30).timestamp() SUNDAY_PLAN = datetime(2026, 10, 18, 9, 30).timestamp()
SUNDAY_SVC = {"date": "261018", "folder": "NA", "name": "261018 NA Project", "label": "SUN 10/18", "title": "Part 8",
"songs": [{"title": "Rejoice Again", "key": "Bb", "bpm": 151}], "salvation": None}
def item(title, type_="item", position="during", description="", notes=None): def item(title, type_="item", position="during", description="", notes=None):
@@ -645,7 +647,8 @@ class TestLook(unittest.TestCase):
src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(), src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(),
lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]), lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]),
resolume=composition, prop_bundle=lambda plan: [["Message Templates.probundle", WEDNESDAY]], resolume=composition, prop_bundle=lambda plan: [["Message Templates.probundle", WEDNESDAY]],
macros=macros_found, companion=lambda n: COMPANION.get(n, {}), live=live_set) macros=macros_found, companion=lambda n: COMPANION.get(n, {}), live=live_set,
services=lambda: [SUNDAY_SVC], start=lambda svcs: {"survey": {SUNDAY_SVC["name"]: {"exists": True}}, "status": None})
src.update(over) src.update(over)
return src return src
+377
View File
@@ -0,0 +1,377 @@
"""START (the week's Ableton sets): which PCO services get a set and what they are called, what the key
shows, and ableton_start.py building a real (small) set on disk: last week's songs saved to Finished Songs,
the new set laid out from the base set, songs, pads, Waves Tune cues and audio copied in.
Run: python3 -m unittest discover -s tests"""
import gzip
import json
import os
import shutil
import struct
import sys
import tempfile
import unittest
import wave
import xml.etree.ElementTree as ET
from datetime import datetime
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import ableton_start as A # noqa: E402
import readiness as rd # noqa: E402
MONDAY = datetime(2026, 10, 26, 9, 0).timestamp()
RC = {"site_folder": "NA", "folder_format": "{date} {loc} Project", "reset_day": "mon", "tracks_host": "tracks",
"projects": "~/P", "locations": ["SC"]}
def pco_item(title, kind="song", pos="during", key=None, arr=None):
return {"id": title, "attributes": {"title": title, "item_type": kind, "service_position": pos, "key_name": key},
"relationships": {"arrangement": {"data": {"id": arr} if arr else None}}}
class TestServices(unittest.TestCase):
def test_songs_and_salvation_from_a_plan(self):
items = [pco_item("Worship", "header"), pco_item("Call To Worship", "media"),
pco_item("Thank God I'm Free", key="E", arr="a1"), pco_item("The Lord Is Here", key="B", arr="a2"),
pco_item("Prayer Graphic", pos="pre"), pco_item("Standing Salvation Moment", "media"),
pco_item("Owe You Praise TAG", key="E", arr="a3"), pco_item("Postservice", pos="post", key=None)]
inc = [{"type": "Arrangement", "id": "a1", "attributes": {"bpm": 128}}, {"type": "Arrangement", "id": "a2", "attributes": {"bpm": 94}},
{"type": "Arrangement", "id": "a3", "attributes": {"bpm": 61}}]
orig = rd.pco_get
rd.pco_get = lambda auth, path, pages=10: (items, inc)
try:
songs, sal = rd.plan_service("x", "1", "p", {})
finally:
rd.pco_get = orig
self.assertEqual([(x["title"], x["key"], x["bpm"]) for x in songs], [("Thank God I'm Free", "E", 128), ("The Lord Is Here", "B", 94)])
self.assertEqual((sal["title"], sal["key"], sal["bpm"]), ("Owe You Praise TAG", "E", 61))
def test_names_sunday_and_first_tuesday(self):
sun = rd.service_entry(RC, True, "North Attleboro | Services", {"id": "1", "attributes": {"title": None}},
datetime(2026, 11, 1, 9, 0).timestamp(), [{"title": "x", "key": "E"}], None)
self.assertEqual((sun["folder"], sun["name"], sun["label"]), ("NA", "261101 NA Project", "SUN 11/1"))
tue = rd.service_entry(RC, False, "North Attleboro | Special Services", {"id": "2", "attributes": {"title": "First Tuesday"}},
datetime(2026, 11, 3, 18, 30).timestamp(), [{"title": "x", "key": "E"}], None)
self.assertEqual((tue["folder"], tue["name"], tue["label"]), ("FIRST TUESDAY", "261103 NA First Tuesday Project", "TUE 11/3"))
other = rd.service_entry(RC, False, "North Attleboro | Morning Worship", {"id": "3", "attributes": {"title": ""}},
datetime(2026, 11, 2, 7, 0).timestamp(), [{"title": "x", "key": "E"}], None)
self.assertEqual((other["folder"], other["name"]), ("NA", "261102 NA Morning Worship Project"))
def test_finds_every_service_with_songs_this_week(self):
def t(id_, name, parent="730820"):
return {"id": id_, "attributes": {"name": name}, "relationships": {"parent": {"data": {"id": parent}}}}
types = [t("275974", "North Attleboro | Services"), t("329152", "North Attleboro | Special Services"),
t("655130", "North Attleboro | Waters Youth"), t("730831", "Woonsocket | Services", "714933")]
plans = {"275974": [("2026-11-01T13:00:00Z", "p1"), ("2026-11-08T13:00:00Z", "p2")], "329152": [("2026-11-03T22:30:00Z", "p3")],
"655130": [("2026-10-30T23:00:00Z", "p4")], "730831": [("2026-11-01T14:00:00Z", "p5")]}
def get(auth, path, pages=10):
if path.startswith("/service_types?"):
return types, []
st = path.split("/")[2]
if "/items" in path:
return [pco_item("Song " + st, key="E")], []
return [{"id": pid, "attributes": {"sort_date": d, "title": "First Tuesday" if st == "329152" else None}}
for d, pid in plans[st]], []
orig = rd.pco_get, rd.pco_login
rd.pco_get, rd.pco_login = get, lambda rc: ("auth", "275974")
rd.SERVICES.clear()
try:
got = rd.upcoming_services(RC, MONDAY)
finally:
rd.pco_get, rd.pco_login = orig
self.assertEqual([x["name"] for x in got], ["261101 NA Project", "261103 NA First Tuesday Project"]) # not youth, not WS, not 11/8
def test_the_key(self):
svc = [{"name": "261101 NA Project", "label": "SUN 11/1", "folder": "NA", "title": ""},
{"name": "261103 NA First Tuesday Project", "label": "TUE 11/3", "folder": "FIRST TUESDAY", "title": "First Tuesday"}]
line = rd.eval_start(RC, svc, {"survey": {"261101 NA Project": {"exists": True}}, "status": None}, MONDAY)
self.assertEqual([m for _, m, _ in line.faults], ["NO SET TUE 11/3"])
both = {"261101 NA Project": {"exists": True}, "261103 NA First Tuesday Project": {"exists": True}}
line = rd.eval_start(RC, svc, {"survey": both, "status": {"state": "done", "time": MONDAY, "results": [
{"name": "261101 NA Project", "notes": ["TRANSPOSE THE LORD IS HERE E>B: CONSOLIDATE IN LIVE", "NEW SONG GRACE: BUILD IT BY HAND"]}]}}, MONDAY + 60)
self.assertEqual([m for _, m, _ in line.faults], ["CONSOLIDATE THE LORD IS.", "NEW SONG GRACE"])
line = rd.eval_start(RC, svc, {"survey": both, "status": None}, MONDAY)
self.assertEqual((line.faults, line.info), ([], "SUN 11/1 TUE 11/3"))
line = rd.eval_start(RC, svc, {"survey": {}, "status": {"state": "running", "time": MONDAY, "step": "COPYING 3/40"}}, MONDAY + 5)
self.assertEqual([m for _, m, _ in line.faults], ["COPYING 3/40"]) # no "NO SET" while it builds
def test_only_where_tracks_are_made(self):
self.assertTrue(rd.start_on(RC))
self.assertFalse(rd.start_on(dict(RC, locations=[]))) # a location that receives
self.assertTrue(rd.start_on(dict(RC, locations=[], start=True)))
# --- a small Live set, written the way Live 11 writes one ---------------------------------------------------
def v(tag, value):
return '<%s Value="%s" />' % (tag, value)
PTR = [100]
def ptr():
PTR[0] += 1
return PTR[0]
def mixer(sends=1):
holders = "".join('<TrackSendHolder Id="%d"><Send>%s<AutomationTarget Id="%d"><LockEnvelope Value="0" /></AutomationTarget>'
'<ModulationTarget Id="%d"><LockEnvelope Value="0" /></ModulationTarget></Send>%s</TrackSendHolder>'
% (i, v("Manual", "0.0003162277571"), ptr(), ptr(), v("Active", "true")) for i in range(sends))
return ('<Mixer><Sends>%s</Sends><Volume>%s<AutomationTarget Id="%d"><LockEnvelope Value="0" /></AutomationTarget></Volume></Mixer>'
% (holders, v("Manual", "1"), ptr()))
def routing(upper="Master", lower="", target="AudioOut/Master"):
return "<AudioOutputRouting>%s%s%s</AudioOutputRouting>" % (v("Target", target), v("UpperDisplayString", upper), v("LowerDisplayString", lower))
def slots(n=2):
return "<ClipSlotList>%s</ClipSlotList>" % "".join('<ClipSlot Id="%d"><ClipSlot><Value /></ClipSlot></ClipSlot>' % i for i in range(n))
def audio_clip(start, end, name, path, rel="", warped=False, secs=None, size=1000):
secs = secs if secs is not None else end - start
loop = "<Loop>%s%s%s%s%s%s%s</Loop>" % (v("LoopStart", 0), v("LoopEnd", secs), v("StartRelative", 0), v("LoopOn", "false"),
v("OutMarker", secs), v("HiddenLoopStart", 0), v("HiddenLoopEnd", secs))
fr = "<FileRef>%s%s%s%s%s</FileRef>" % (v("RelativePathType", 3 if rel else 0), v("RelativePath", rel), v("Path", path),
v("OriginalFileSize", size), v("OriginalCrc", 1))
return ('<AudioClip Id="0" Time="%s">%s%s%s%s%s%s<SampleRef>%s%s%s%s</SampleRef>%s%s%s%s%s<WarpMarkers /></AudioClip>'
% (start, v("CurrentStart", start), v("CurrentEnd", end), loop, v("Name", name), v("Disabled", "false"),
v("IsWarped", "true" if warped else "false"), fr, v("LastModDate", 0), v("DefaultDuration", 100), v("DefaultSampleRate", 48000),
v("WarpMode", 0), v("ComplexProFormants", 100), v("PitchCoarse", 0), v("Fade", "true"), v("Sync", "true")))
def midi_clip(start, end, name, notes=(), program=None):
kt = ('<Notes><KeyTracks><KeyTrack Id="0"><Notes>%s</Notes>%s</KeyTrack></KeyTracks></Notes>'
% ("".join('<MidiNoteEvent Time="%s" Duration="0.25" Velocity="100" IsEnabled="true" />' % n for n in notes), v("MidiKey", 66))
if notes else "<Notes><KeyTracks /></Notes>")
return ('<MidiClip Id="0" Time="%s">%s%s<Loop>%s%s</Loop>%s%s%s%s</MidiClip>'
% (start, v("CurrentStart", start), v("CurrentEnd", end), v("LoopStart", 0), v("LoopEnd", end - start), v("Name", name),
v("Disabled", "false"), kt, v("ProgramChange", program if program is not None else -1)))
def track(kind, id_, name, group="-1", clips="", out=("Master", "", "AudioOut/Master")):
seq = ("<Sample><ArrangerAutomation><Events>%s</Events></ArrangerAutomation></Sample>" if kind == "AudioTrack" else
"<ClipTimeable><ArrangerAutomation><Events>%s</Events></ArrangerAutomation></ClipTimeable>") % clips
if kind == "GroupTrack":
return ('<GroupTrack Id="%s"><Name>%s%s</Name>%s<Slots><GroupTrackSlot Id="0" /><GroupTrackSlot Id="1" /></Slots>'
'<DeviceChain>%s%s</DeviceChain></GroupTrack>' % (id_, v("EffectiveName", name), v("UserName", name), v("TrackGroupId", group),
mixer(), routing(*out)))
return ('<%s Id="%s"><Name>%s%s</Name>%s<DeviceChain>%s%s<MainSequencer>%s%s</MainSequencer></DeviceChain>'
'<AutomationEnvelopes><Envelopes /></AutomationEnvelopes></%s>'
% (kind, id_, v("EffectiveName", name), v("UserName", name), v("TrackGroupId", group), mixer(), routing(*out), slots(), seq, kind))
def live_set(tracks_xml, locators, tempo=72):
locs = "".join('<Locator Id="%d">%s%s%s<Mapping>%s%s</Mapping></Locator>' % (
i, v("Time", t), v("Name", n), v("Annotation", ""), v("PersistentKeyString", k), v("NoteOrController", -1))
for i, (t, n, k) in enumerate(locators))
return ('<?xml version="1.0" encoding="UTF-8"?><Ableton MajorVersion="5" Creator="Ableton Live 11.3.43"><LiveSet>%s'
'<Tracks>%s<ReturnTrack Id="900"><Name>%s%s</Name>%s<DeviceChain>%s%s</DeviceChain></ReturnTrack></Tracks>'
'<MasterTrack><DeviceChain><Mixer><Tempo>%s<AutomationTarget Id="8"><LockEnvelope Value="0" /></AutomationTarget></Tempo></Mixer></DeviceChain>'
'<AutomationEnvelopes><Envelopes><AutomationEnvelope Id="0"><EnvelopeTarget>%s</EnvelopeTarget><Automation><Events>'
'<FloatEvent Id="1" Time="-63072000" Value="%s" /></Events></Automation></AutomationEnvelope></Envelopes></AutomationEnvelopes></MasterTrack>'
'<Scenes><Scene Id="0" /><Scene Id="1" /></Scenes><Locators><Locators>%s</Locators></Locators></LiveSet></Ableton>'
% (v("NextPointeeId", 5000), tracks_xml, v("EffectiveName", "A-IN EAR MIX"), v("UserName", "A-IN EAR MIX"), v("TrackGroupId", -1),
mixer(0), routing(), v("Manual", tempo), v("PointeeId", 8), tempo, locs))
def write_als(path, xml):
os.makedirs(os.path.dirname(path), exist_ok=True)
with gzip.open(path, "wb") as f:
f.write(xml.encode())
def write_wav(path, seconds=2.0, rate=8000):
os.makedirs(os.path.dirname(path), exist_ok=True)
w = wave.open(path, "wb")
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(b"\0\0" * int(seconds * rate))
w.close()
class TestBuild(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir)
d = self.dir
self.proj = os.path.join(d, "Projects")
base = os.path.join(self.proj, "NA", "261025 NA Project")
write_wav(os.path.join(base, "Samples/Imported/Piano.wav"))
write_wav(os.path.join(base, "Samples/Imported/D# - WARM - CHURCHFRONT PADS.wav"), 600)
write_wav(os.path.join(base, "Samples/Imported/LTC.wav"))
write_wav(os.path.join(base, "Samples/Imported/Guide.wav"))
p = lambda n: os.path.join(base, "Samples/Imported", n)
ext = ("Ext. Out", "5/6 Keys", "AudioOut/External/S2")
last_week = (
track("AudioTrack", 57, "TEMPO", clips=audio_clip(0, 1132, "143.00 BPM", "Samples/Recorded/x.aif", warped=True)) +
track("AudioTrack", 58, "LTC", clips=audio_clip(0, 4000, "LTC", p("LTC.wav"), "Samples/Imported/LTC.wav", True)) +
track("MidiTrack", 59, "SECTIONS", clips=midi_clip(0, 196, "CALL TO WORSHIP") + midi_clip(196, 236, "INTRO")) +
track("GroupTrack", 60, "MIDI GROUP") +
track("MidiTrack", 61, "Loop Cue", "60", midi_clip(138, 138.5, "", [0]) + midi_clip(699, 700, "") + midi_clip(1120.5, 1121, "", [0])) +
track("MidiTrack", 62, "WAVES TUNE", "60", midi_clip(0, 1, "Ebmaj", program=7) + midi_clip(196, 197, "Ebmaj", program=7)) +
track("MidiTrack", 63, "LIGHTING CUES", "60", midi_clip(196, 900, "LIGHTING CUES 56")) +
track("GroupTrack", 64, "RESI TRACKS") +
track("AudioTrack", 65, "+GUIDE", clips=audio_clip(196, 900, "Guide", p("Guide.wav"), "Samples/Imported/Guide.wav", True)) +
track("AudioTrack", 66, "+LOOPGUIDE") +
track("AudioTrack", 67, "AMBIENT PADS", clips=audio_clip(0, 237, "D# - WARM - CHURCHFRONT PADS", p("D# - WARM - CHURCHFRONT PADS.wav"),
"Samples/Imported/D# - WARM - CHURCHFRONT PADS.wav", secs=99.4)) +
track("GroupTrack", 70, "OLD SONG") +
track("AudioTrack", 71, "21-Piano", "70", audio_clip(196, 900, "Piano", p("Piano.wav"), "Samples/Imported/Piano.wav", secs=295.1), ext) +
track("AudioTrack", 72, "22-EG 1", "70", audio_clip(196, 900, "EG 1", p("Piano.wav"), "Samples/Imported/Piano.wav", secs=295.1),
("Ext. Out", "7/8 GTRS", "AudioOut/External/S3")))
locators = [(0, "1- CALL TO WORSHIP ", "1"), (76, "LOOP", ""), (196, "2 - OLD SONG- Eb", "2"), (1090, "LOOP", ""),
(1132, "3- SALVATION- Eb", "3"), (1500, "LOOP", "")]
write_als(os.path.join(base, "261025 NA Project.als"), live_set(last_week, locators, 143))
# Finished Songs: one song in E (to be used in E and, transposed, in B).
self.fin = os.path.join(d, "Finished Songs")
song = os.path.join(self.fin, "GRACE [E]")
write_wav(os.path.join(song, "Samples/Processed/Consolidate/Keys [x].aif".replace(".aif", ".wav")))
write_wav(os.path.join(song, "Samples/Processed/Consolidate/Guide [2026-01-01 000000].wav"))
c = lambda n: os.path.join(song, "Samples/Processed/Consolidate", n)
song_tracks = (
track("GroupTrack", 0, "GRACE") +
track("MidiTrack", 1, "2-MIDI", "0", midi_clip(1992, 2024, "INTRO") + midi_clip(2024, 2056, "VERSE 1")) +
track("MidiTrack", 2, "3-MIDI", "0", midi_clip(1992, 2300, "LIGHTING CUES 47")) +
track("AudioTrack", 3, "4-Guide [2026-01-01 000000]", "0", audio_clip(1992, 2300, "Guide", c("Guide [2026-01-01 000000].wav"),
"Samples/Processed/Consolidate/Guide [2026-01-01 000000].wav", True)) +
track("AudioTrack", 4, "5-Keys 1 [x]", "0", audio_clip(1992, 2368, "Keys 1", c("Keys [x].wav"), "Samples/Processed/Consolidate/Keys [x].wav",
secs=313.3333333), ext))
write_als(os.path.join(song, "GRACE.als"), live_set(song_tracks, [], 143))
self.pads = os.path.join(d, "PADS")
for k in ("E", "B", "D#"):
write_wav(os.path.join(self.pads, "%s - WARM - CHURCHFRONT PADS.wav" % k), 600)
self.tune = os.path.join(d, "Waves Tune")
for name, prog in (("Emaj", 4), ("Bmaj", 11), ("Ebmaj", 7)):
write_als(os.path.join(self.tune, name + ".alc"), '<Ableton><MidiClip>%s</MidiClip></Ableton>' % v("ProgramChange", prog))
A.STATUS = os.path.join(d, "status.json")
self.cfg = {"projects": self.proj, "site_folder": "NA", "folder_format": "{date} {loc} Project",
"finished_folders": [self.fin], "pads_folder": self.pads, "tune_library": self.tune,
"routing": {"KEYS 1": ["Ext. Out", "5/6 Keys"], "EG 1": ["Ext. Out", "7/8 GTRS"]},
"keep_groups": ["MIDI GROUP", "RESI TRACKS"], "pad_track": "AMBIENT PADS", "tune_track": "WAVES TUNE",
"lighting_track": "LIGHTING CUES", "sections_track": "SECTIONS", "guide_track": "+GUIDE",
"loopguide_track": "+LOOPGUIDE", "loop_cue_track": "Loop Cue", "tempo_track": "TEMPO", "send_a": 1.0,
"services": [{"date": "261101", "folder": "NA", "name": "261101 NA Project", "label": "SUN 11/1",
"songs": [{"title": "Grace", "key": "E", "bpm": 72}, {"title": "Grace", "key": "B", "bpm": 72},
{"title": "Brand New", "key": "D", "bpm": 120}],
"salvation": {"title": "Grace TAG", "key": "B", "bpm": 72}}]}
def run_job(self):
A.run(self.cfg)
with open(A.STATUS) as f:
return json.load(f)
def test_builds_the_weeks_set(self):
st = self.run_job()
self.assertEqual(st["state"], "done", st.get("trace"))
# 1. Last week's song went to Finished Songs, with its markers, lights and guide inside the group.
self.assertEqual(st["saved"], ["OLD SONG [Eb]"])
old = A.load(os.path.join(self.fin, "OLD SONG [Eb]", "OLD SONG.als")).find("LiveSet")
self.assertEqual([A.tname(t) for t in A.tracks(old) if t.tag != "ReturnTrack"],
["OLD SONG", "SECTIONS", "LIGHTING CUES", "Guide", "21-Piano", "22-EG 1"])
self.assertTrue(os.path.exists(os.path.join(self.fin, "OLD SONG [Eb]", "Samples/Imported/Piano.wav")))
# 2-6. The new set.
path = os.path.join(self.proj, "NA", "261101 NA Project", "261101 NA Project.als")
root = A.load(path)
ls = root.find("LiveSet")
self.assertEqual(A.check(root), [])
locs = sorted((A.fval(l, "Time"), A.val(l, "Name"), A.val(l, "Mapping/PersistentKeyString")) for l in ls.findall("Locators/Locators/Locator"))
names = [(n, k) for _, n, k in locs if not n.startswith("LOOP")]
self.assertEqual(names, [("1- CALL TO WORSHIP - E", "1"), ("2- GRACE - E", "2"), ("3- GRACE - B", "3"),
("4- BRAND NEW - D", "4"), ("5- SALVATION - B", "5")])
by = {A.tname(t): t for t in A.tracks(ls)}
# The 376-beat song pushed the 1090 loop along: the loop starts after the song ends.
loops = [t for t, n, _ in locs if n.startswith("LOOP")]
self.assertEqual(loops[:2], [76.0, 1090.0])
self.assertEqual([A.span(c)[0] for c in A.clips(by["Loop Cue"])][:2], [138.0, 1120.5])
self.assertNotIn(699.0, [A.span(c)[0] for c in A.clips(by["Loop Cue"])]) # an empty cue does nothing
tunes = [(A.span(c)[0], A.val(c, "Name"), A.val(c, "ProgramChange")) for c in A.clips(by["WAVES TUNE"])]
self.assertEqual([t[1:] for t in tunes], [("Emaj", "4"), ("Emaj", "4"), ("Bmaj", "11"), ("Bmaj", "11")]) # no D cue in the library
pads = [(A.val(c, "Name")[:2], A.span(c)) for c in A.clips(by["AMBIENT PADS"])]
self.assertEqual([p[0] for p in pads], ["E ", "E ", "B ", "B "]) # no D pad: named in the notes
self.assertEqual([A.tname(t) for t in A.tracks(ls) if t.tag == "GroupTrack" and A.group_of(t) == "-1"],
["MIDI GROUP", "RESI TRACKS", "GRACE", "GRACE"])
g2 = [t for t in A.tracks(ls) if A.tname(t) == "GRACE"][1]
keys = [t for t in A.family(ls, g2) if t.tag == "AudioTrack"][0]
c = A.clips(keys)[0]
self.assertEqual((A.val(c, "IsWarped"), A.val(c, "WarpMode"), A.val(c, "ComplexProFormants"), A.val(c, "PitchCoarse")),
("true", "6", "0", "-5"))
self.assertEqual(A.val(keys, "DeviceChain/Mixer/Sends/TrackSendHolder/Send/Manual"), "1")
self.assertEqual(A.val(keys, "DeviceChain/AudioOutputRouting/Target"), "AudioOut/External/S2")
self.assertEqual([A.val(c, "Name") for c in A.clips(by["SECTIONS"])], ["CALL TO WORSHIP", "INTRO", "VERSE 1", "INTRO", "VERSE 1"])
self.assertEqual(len(A.clips(by["+GUIDE"])), 2)
self.assertEqual(A.clips(by["+LOOPGUIDE"]), [])
fr = c.find("SampleRef/FileRef")
self.assertEqual(A.val(fr, "RelativePath"), "Samples/Imported/GRACE [E]/Keys [x].wav")
self.assertTrue(os.path.exists(os.path.join(os.path.dirname(path), "Samples/Imported/GRACE [E]/Keys [x].wav")))
self.assertTrue(os.path.exists(os.path.join(os.path.dirname(path), "Samples/Imported/LTC.wav")))
tempo = [(A.span(c)[0], A.val(c, "Name")) for c in A.clips(by["TEMPO"])]
self.assertEqual(tempo[0], (0.0, "72.00 BPM"))
self.assertIn((tempo[-1][0], "72.00 BPM"), tempo)
notes = st["results"][0]["notes"]
self.assertIn("TRANSPOSE GRACE E>B: CONSOLIDATE IN LIVE", notes)
self.assertIn("NEW SONG BRAND NEW: BUILD IT BY HAND", notes)
self.assertIn("NO PAD IN D", notes)
self.assertFalse([n for n in os.listdir(os.path.join(self.proj, "NA")) if n.endswith(".building")])
self.assertTrue(os.path.isdir(os.path.join(os.path.dirname(path), "Ableton Project Info"))) # else Live finds no audio
self.assertTrue(os.path.isdir(os.path.join(self.fin, "OLD SONG [Eb]", "Ableton Project Info")))
def test_a_set_that_is_there_is_left_alone(self):
os.makedirs(os.path.join(self.proj, "NA", "261101 NA Project"))
st = self.run_job()
self.assertEqual(st["step"], "NOTHING TO DO")
self.assertEqual(os.listdir(os.path.join(self.proj, "NA", "261101 NA Project")), [])
def test_an_unchanged_song_is_not_saved_again_and_a_changed_one_parks_the_old(self):
self.run_job()
shutil.rmtree(os.path.join(self.proj, "NA", "261101 NA Project"))
st = self.run_job()
self.assertEqual(st["saved"], [])
self.assertFalse(os.path.exists(os.path.join(self.fin, "zOLD SONG [Eb]")))
# A re-consolidated stem (new file) is a new version: the old folder is parked as z.
base = os.path.join(self.proj, "NA", "261025 NA Project", "261025 NA Project.als")
xml = gzip.open(base).read().decode()
i = xml.index("21-Piano")
xml = xml[:i] + xml[i:].replace('Value="1000"', 'Value="2000"', 1)
write_als(base, xml)
shutil.rmtree(os.path.join(self.proj, "NA", "261101 NA Project"))
st = self.run_job()
self.assertEqual(st["saved"], ["OLD SONG [Eb]"])
self.assertTrue(os.path.isdir(os.path.join(self.fin, "zOLD SONG [Eb]")))
def test_a_changed_song_replaces_the_version_it_was_made_from(self):
# Last week's "OLD SONG" group was made from "OLD SONG LIVE [Eb]", then edited (a stem swapped).
song = os.path.join(self.fin, "OLD SONG LIVE [Eb]")
write_wav(os.path.join(song, "Samples/Piano.wav"))
write_wav(os.path.join(song, "Samples/Keys.wav"))
write_als(os.path.join(song, "OLD SONG LIVE.als"), live_set(
track("GroupTrack", 0, "OLD SONG LIVE") +
track("AudioTrack", 1, "Piano", "0", audio_clip(0, 10, "Piano", os.path.join(song, "Samples/Piano.wav"), "Samples/Piano.wav")) +
track("AudioTrack", 2, "Keys", "0", audio_clip(0, 10, "Keys", os.path.join(song, "Samples/Keys.wav"), "Samples/Keys.wav")), []))
st = self.run_job()
self.assertEqual(st["saved"], ["OLD SONG LIVE [Eb]"])
self.assertTrue(os.path.isdir(os.path.join(self.fin, "zOLD SONG LIVE [Eb]")))
self.assertTrue(os.path.exists(os.path.join(self.fin, "OLD SONG LIVE [Eb]", "OLD SONG LIVE.als")))
self.assertFalse(os.path.exists(os.path.join(self.fin, "OLD SONG [Eb]")))
def test_titles(self):
self.assertTrue(A.close_titles("CAN'T HELP BUT PRAISE", "Can't Help But Praise LM"))
self.assertFalse(A.close_titles("PRAISE", "Owe You Praise"))
self.assertEqual(A.semitones("E", "B"), -5)
self.assertEqual(A.semitones("G", "A"), 2)
self.assertEqual(A.pick_version([("/f/GRACE [E]", "GRACE", "E"), ("/f/GRACE [B]", "GRACE", "B")], "Grace", "B")[0][2], "B")
def test_audio_info(self):
p = os.path.join(self.dir, "x.wav")
write_wav(p, 3.0, 8000)
self.assertEqual(A.audio_info(p), (24000, 8000))
if __name__ == "__main__":
unittest.main()