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:
@@ -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
File diff suppressed because it is too large
Load Diff
+22
@@ -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
@@ -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.
@@ -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": [
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user