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>
378 lines
25 KiB
Python
378 lines
25 KiB
Python
"""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()
|