START: bright red guide, no transposing unpitched stems, consolidate + warp off in Live
Per the Location Setup worksheet (2026-10-26): - a song saved twice holds two guides; START takes the bright red one (Live clip color 14), not the newer light pink one - drums, percussion, aux drums and other unpitched stems are never transposed (by stem name; sub bass and FX are) - every transposed clip is consolidated in Live (Edit > Consolidate) and its warp turned off, then the set is saved; a hold of START does it for this week's existing sets too. New AbletonOSC commands: clip, select_clip, set_clip - the START key's CONSOLIDATE comes from the saved set (clips still transposed), not the last build's log Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -166,7 +166,7 @@ everything on the page at once.
|
|||||||
| 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` 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. |
|
| 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. |
|
||||||
| RESI VOX | the Resi song (PCO description says RESI, else the last song before the message) goes to the locations with its lead singer's mic: the lead from the description ("Genesis lead"), their VOX from the lineup screen ("[VOX 4] MIX 4"; PCO team positions if the lineup has none), against what the Resi AJA's `5 - Lead Vocal` channel (21) really receives, read from the card with Dante's own query (UDP 4440, read only). **RESI V2 NOT VOX 4** (red). Two leads ("Mike and DJ lead"): the song's Audio note in PCO says whose mic goes out ("V3" or a name); without one it is flagged, never switched: **2 LEADS: MIKE DJ**. Also only flagged: **JOSH NOT ON LINEUP**, **LEAD ON VOX 5: NO BUTTON**. The lineup is what counts: a PCO Audio note naming another VOX is only mentioned in the details. | presses that VOX's AJA button (Companion page 13, row 3, col 1-4: AJA ch 21 <- BOH-AD4Q-A ch n), then reads the AJA back. Also an auto-fix (any time, never while a deck records: the READY page is not looked at then). |
|
| RESI VOX | the Resi song (PCO description says RESI, else the last song before the message) goes to the locations with its lead singer's mic: the lead from the description ("Genesis lead"), their VOX from the lineup screen ("[VOX 4] MIX 4"; PCO team positions if the lineup has none), against what the Resi AJA's `5 - Lead Vocal` channel (21) really receives, read from the card with Dante's own query (UDP 4440, read only). **RESI V2 NOT VOX 4** (red). Two leads ("Mike and DJ lead"): the song's Audio note in PCO says whose mic goes out ("V3" or a name); without one it is flagged, never switched: **2 LEADS: MIKE DJ**. Also only flagged: **JOSH NOT ON LINEUP**, **LEAD ON VOX 5: NO BUTTON**. The lineup is what counts: a PCO Audio note naming another VOX is only mentioned in the details. | presses that VOX's AJA button (Companion page 13, row 3, col 1-4: AJA ch 21 <- BOH-AD4Q-A ch n), then reads the AJA back. Also an auto-fix (any time, never while a deck records: the READY page is not looked at then). |
|
||||||
| 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. |
|
| 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**). **CONSOLIDATE THE LORD IS**: the saved set still has clips of that song transposed and not consolidated (read from the set file, so it goes once the set is saved consolidated). After a build, for a day: **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), then consolidates every transposed clip in Live (step 9 below). 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.
|
||||||
@@ -178,7 +178,7 @@ 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 an Ableton set file (START only writes new sets and Finished Songs folders). 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, and consolidates in Live). 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)
|
### START: the week's Ableton sets (Monday)
|
||||||
@@ -207,12 +207,30 @@ other special service `Projects/NA/<date> NA <plan title> Project`.
|
|||||||
4. **Tempo map** and one **TEMPO** label clip per tempo (`128.00 BPM`). The tempo is the one the song's audio was
|
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.
|
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).
|
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
|
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
|
then its **pitched** stems are transposed the PDF's way (warp on, Complex Pro, formants 0, transpose). Drums,
|
||||||
is done in Live. Its markers, guide and lighting cues go onto `SECTIONS`, `+GUIDE` and `LIGHTING CUES`.
|
percussion, aux drums and other unpitched stems are never transposed: by name, `DRUMS`, `PERC`, `SHAKER`,
|
||||||
|
`TAMBOURINE`, `CYMBALS`, `TOMS`, `CLAPS`, `KICK`/`HIT`/`IMPACT`/`BOOM` FX, a plain `LOOP`, clicks and guides
|
||||||
|
(`ready.start_unpitched` replaces the list). Sub bass, `FX`, risers, `TIMPANI` and `SYNTH`/`VOX LOOP` are pitched.
|
||||||
|
Its markers, guide and lighting cues go onto `SECTIONS`, `+GUIDE` and `LIGHTING CUES`. **The guide is the bright
|
||||||
|
red one** (Live's clip color 14): a song saved twice holds two guides, and the newer one is often light pink
|
||||||
|
(colors 0 / 28: ALLELUIA, COVERED BY THE BLOOD, FULL SURRENDER, GOODBYE YESTERDAY, PROMISED LAND, THANK GOD I'M
|
||||||
|
FREE, WHAT A GOD in October 2026). Two red ones: the newer red (`ready.start_guide_color`).
|
||||||
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,
|
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.
|
Salvation's.
|
||||||
8. **Routing** per `ready.routing` and **send A at 0 dB** on every stem.
|
8. **Routing** per `ready.routing` and **send A at 0 dB** on every stem.
|
||||||
|
9. **Consolidate, warp off** (in Live, `ready.start_consolidate`): the PDF's order is warp on, Complex Pro,
|
||||||
|
formants 0, transpose, **consolidate, warp off**. The first four are in the set file; for the last two the job opens
|
||||||
|
the set in Live (when another set is open, and never while Live plays; Live asks about unsaved changes itself, and
|
||||||
|
the job gives up after 3 minutes: **OPEN ... IN LIVE**, hold again), then for every clip still transposed: selects
|
||||||
|
it in the arrangement, clicks Edit › Consolidate (Live renders it with Complex Pro into
|
||||||
|
`Samples/Processed/Consolidate`), turns the new clip's warp off, and at the end clicks File › Save Live Set. An
|
||||||
|
unpitched clip found transposed (a set built before 2026-10-26) only goes back to 0 with warp off. A clip that is
|
||||||
|
not where the saved set has it (changed in Live since) is left and named. The key shows **CONSOLIDATING 12/49**;
|
||||||
|
ROUTING and IN EARS show START IN LIVE meanwhile (the job holds AbletonOSC's answers on the Tracks Mac), and once
|
||||||
|
it is done NA Ingest hands AbletonOSC back to Companion. A hold of START does this for this week's sets that are
|
||||||
|
already there too. Uses the status panel's AbletonOSC commands (`clip`, `select_clip`, `set_clip` in
|
||||||
|
`tools/abletonosc/statuspanel.py`).
|
||||||
|
|
||||||
Every audio file is copied into the new project (as Collect All and Save does) with an `Ableton Project Info` folder
|
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
|
(Live finds no audio without it: 2026-10-22). A set that is already there is never touched; a new one is built as
|
||||||
|
|||||||
+264
-15
@@ -12,15 +12,21 @@ every special service (First / Together Tuesday, Morning Worship, ...).
|
|||||||
the numbered locators (with their computer-key mappings), the loop locators and the loop cues stay.
|
the numbered locators (with their computer-key mappings), the loop locators and the loop cues stay.
|
||||||
3 the locators are named "N- TITLE - KEY" from PCO, the tempo map and the TEMPO label clips follow the
|
3 the locators are named "N- TITLE - KEY" from PCO, the tempo map and the TEMPO label clips follow the
|
||||||
songs, and a Waves Tune key cue goes at every locator
|
songs, and a Waves Tune key cue goes at every locator
|
||||||
4 every song comes in from Finished Songs at its locator. In another key it is transposed the way the
|
4 every song comes in from Finished Songs at its locator. In another key its pitched stems are transposed
|
||||||
workflow says (warp on, Complex Pro, formants 0, transpose) and flagged, because consolidating it is
|
the way the workflow says (warp on, Complex Pro, formants 0, transpose); drums, percussion, aux drums
|
||||||
done in Live. Its markers, guide and lighting cues go onto the dedicated tracks.
|
and other unpitched stems are left as they are (UNPITCHED). Its markers, bright red guide (a song saved
|
||||||
|
twice holds a light pink one too) and lighting cues go onto the dedicated tracks.
|
||||||
5 pads: the Call to Worship pad in the first song's key, an ending pad per song past its loop, Salvation
|
5 pads: the Call to Worship pad in the first song's key, an ending pad per song past its loop, Salvation
|
||||||
6 every stem routed per the routing template, send A (in-ears) at 0 dB
|
6 every stem routed per the routing template, send A (in-ears) at 0 dB
|
||||||
|
|
||||||
A song too long for its slot pushes the loop locators, loop cues and later slots along (in whole bars);
|
A song too long for its slot pushes the loop locators, loop cues and later slots along (in whole bars);
|
||||||
more songs than slots add slots; fewer move Salvation up, as the sets have done by hand.
|
more songs than slots add slots; fewer move Salvation up, as the sets have done by hand.
|
||||||
|
|
||||||
|
7 in Live: every transposed clip is consolidated and its warp turned off (the workflow's last two steps;
|
||||||
|
Live's own Consolidate, so Complex Pro renders it), and the set saved. The set is opened in Live first
|
||||||
|
when another one is open (never while Live plays; Live asks about unsaved changes itself). A hold of
|
||||||
|
START does this for a set that is already there too.
|
||||||
|
|
||||||
Every audio file the set uses is copied into its project folder (as Collect All and Save does), so the
|
Every audio file the set uses is copied into its project folder (as Collect All and Save does), so the
|
||||||
project can be sent to the locations as it is. Nothing is overwritten: a set that is there is left alone,
|
project can be sent to the locations as it is. Nothing is overwritten: a set that is there is left alone,
|
||||||
and a new set is built in "<name>.building" and only renamed when it is complete.
|
and a new set is built in "<name>.building" and only renamed when it is complete.
|
||||||
@@ -46,6 +52,11 @@ NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
|||||||
FINISHED = re.compile(r"^(.*?)\s*\[\s*([A-G](?:#|b)?m?)\s*\]\s*(?:-\s*CUSTOM)?\s*$", re.I)
|
FINISHED = re.compile(r"^(.*?)\s*\[\s*([A-G](?:#|b)?m?)\s*\]\s*(?:-\s*CUSTOM)?\s*$", re.I)
|
||||||
NUMBERED = re.compile(r"^\s*(\d+)\s*-")
|
NUMBERED = re.compile(r"^\s*(\d+)\s*-")
|
||||||
SALVATION = re.compile(r"salvation", re.I)
|
SALVATION = re.compile(r"salvation", re.I)
|
||||||
|
# Stems that have no key: never transposed (the workflow: "drums, percussion, aux drums, and other unpitched
|
||||||
|
# sounds"). Sub bass, timpani and FX are pitched. A bare LOOP stem is a drum loop (SYNTH / VOX LOOP are not).
|
||||||
|
UNPITCHED = (r"\b(DRUMS?|PERC(USSION)?|SHAKERS?|TAMB(OURINE)?S?|CYMBALS?|TOMS|KICK|SNARE|HI ?HATS?|HATS|CLAPS?|SNAPS"
|
||||||
|
r"|TIMBALES|CONGAS?|BONGOS?|CAJON|HITS?|IMPACT|BOOM|CLICK|BPM|TEMPO|LTC|GUIDE)\b|^LOOP\b")
|
||||||
|
GUIDE_RED = 14 # Live's bright red (FF3636); the other guide is light pink (0 or 28)
|
||||||
AUDIO_EVENTS = "DeviceChain/MainSequencer/Sample/ArrangerAutomation/Events"
|
AUDIO_EVENTS = "DeviceChain/MainSequencer/Sample/ArrangerAutomation/Events"
|
||||||
MIDI_EVENTS = "DeviceChain/MainSequencer/ClipTimeable/ArrangerAutomation/Events"
|
MIDI_EVENTS = "DeviceChain/MainSequencer/ClipTimeable/ArrangerAutomation/Events"
|
||||||
INITIAL = -63072000.0 # the time Live gives an envelope's starting value
|
INITIAL = -63072000.0 # the time Live gives an envelope's starting value
|
||||||
@@ -117,6 +128,11 @@ def stem_name(name):
|
|||||||
return " ".join(s.split())
|
return " ".join(s.split())
|
||||||
|
|
||||||
|
|
||||||
|
def pitched(name, unpitched=UNPITCHED):
|
||||||
|
"""A stem that follows the song's key ("Sub Bass", "Piano 2"), not a drum or percussion one."""
|
||||||
|
return not re.search(unpitched, stem_name(name).replace("_", " "))
|
||||||
|
|
||||||
|
|
||||||
def template_for(routing, name):
|
def template_for(routing, name):
|
||||||
n = stem_name(name)
|
n = stem_name(name)
|
||||||
if n in routing:
|
if n in routing:
|
||||||
@@ -894,9 +910,9 @@ def dated(t):
|
|||||||
return m.group(1) if m else ""
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
def song_parts(fam):
|
def song_parts(fam, red=GUIDE_RED):
|
||||||
"""A finished song's group split into its stems and the three helper tracks the workflow puts in it
|
"""A finished song's group split into its stems and the three helper tracks the workflow puts in it
|
||||||
(one of each: a song saved twice can hold two guides; the newest is the one)."""
|
(one of each). A song saved twice holds two guides: the bright red one is the one (the newest red)."""
|
||||||
stems, sections, lighting, guide = [], [], [], []
|
stems, sections, lighting, guide = [], [], [], []
|
||||||
for t in fam[1:]:
|
for t in fam[1:]:
|
||||||
cs = clips(t)
|
cs = clips(t)
|
||||||
@@ -911,7 +927,7 @@ def song_parts(fam):
|
|||||||
stems.append(t)
|
stems.append(t)
|
||||||
pick = lambda ts, key: clips(max(ts, key=key)) if ts else []
|
pick = lambda ts, key: clips(max(ts, key=key)) if ts else []
|
||||||
return (stems, pick(sections, lambda t: len(clips(t))), pick(lighting, lambda t: len(clips(t))),
|
return (stems, pick(sections, lambda t: len(clips(t))), pick(lighting, lambda t: len(clips(t))),
|
||||||
pick(guide, lambda t: (dated(t), -fam.index(t))))
|
pick(guide, lambda t: (any(val(c, "Color") == str(red) for c in clips(t)), dated(t), -fam.index(t))))
|
||||||
|
|
||||||
|
|
||||||
def load_song(entry, cfg):
|
def load_song(entry, cfg):
|
||||||
@@ -926,7 +942,7 @@ def load_song(entry, cfg):
|
|||||||
if not tops:
|
if not tops:
|
||||||
return None
|
return None
|
||||||
fam = family(ls, tops[0])
|
fam = family(ls, tops[0])
|
||||||
stems, sections, lighting, guide = song_parts(fam)
|
stems, sections, lighting, guide = song_parts(fam, cfg.get("guide_color", GUIDE_RED))
|
||||||
audio = [c for t in stems for c in clips(t) if c.tag == "AudioClip"]
|
audio = [c for t in stems for c in clips(t) if c.tag == "AudioClip"]
|
||||||
if not audio:
|
if not audio:
|
||||||
return None
|
return None
|
||||||
@@ -971,7 +987,7 @@ def build_set(svc, base_path, out_dir, name, cfg, status, log):
|
|||||||
if got:
|
if got:
|
||||||
info.update(got, semis=semis, key=s["key"], from_key=got["key"])
|
info.update(got, semis=semis, key=s["key"], from_key=got["key"])
|
||||||
if semis:
|
if semis:
|
||||||
log.append("TRANSPOSE %s %s>%s: CONSOLIDATE IN LIVE" % (got["title"].upper(), got["key"], s["key"]))
|
log.append("TRANSPOSE %s %s>%s" % (got["title"].upper(), got["key"], s["key"]))
|
||||||
if s.get("bpm") and abs(float(s["bpm"]) - got["bpm"]) > 0.6:
|
if s.get("bpm") and abs(float(s["bpm"]) - got["bpm"]) > 0.6:
|
||||||
log.append("TEMPO %s: PCO %s, SONG %g" % (got["title"].upper(), s["bpm"], got["bpm"]))
|
log.append("TEMPO %s: PCO %s, SONG %g" % (got["title"].upper(), s["bpm"], got["bpm"]))
|
||||||
else:
|
else:
|
||||||
@@ -1097,7 +1113,7 @@ def build_set(svc, base_path, out_dir, name, cfg, status, log):
|
|||||||
for c in clips(t):
|
for c in clips(t):
|
||||||
place(c, span(c)[0] + off, song["bpm"])
|
place(c, span(c)[0] + off, song["bpm"])
|
||||||
if c.tag == "AudioClip":
|
if c.tag == "AudioClip":
|
||||||
if song.get("semis"):
|
if song.get("semis") and pitched(tname(t), cfg.get("unpitched") or UNPITCHED):
|
||||||
transpose(c, song["semis"], song["bpm"])
|
transpose(c, song["semis"], song["bpm"])
|
||||||
files.adopt(c, song["proj"], song["folder"])
|
files.adopt(c, song["proj"], song["folder"])
|
||||||
if t.tag == "AudioTrack":
|
if t.tag == "AudioTrack":
|
||||||
@@ -1237,6 +1253,220 @@ def check(root):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- 7: in Live, consolidating what was transposed ---------------------------------------------------
|
||||||
|
|
||||||
|
def pending(root, unpitched=UNPITCHED):
|
||||||
|
"""The transposed clips a set still holds: [(track index as Live counts them (no returns), track name, clip
|
||||||
|
start, pitched)]. A pitched one is consolidated; an unpitched one only goes back to its own key."""
|
||||||
|
out, i = [], 0
|
||||||
|
for t in tracks(root.find("LiveSet")):
|
||||||
|
if t.tag == "ReturnTrack":
|
||||||
|
continue
|
||||||
|
for c in clips(t):
|
||||||
|
if c.tag == "AudioClip" and int(fval(c, "PitchCoarse")) != 0:
|
||||||
|
out.append((i, tname(t), span(c)[0], pitched(tname(t), unpitched)))
|
||||||
|
i += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def live_set():
|
||||||
|
"""The set open in Live: the last one its log says it loaded."""
|
||||||
|
import glob
|
||||||
|
logs = sorted(glob.glob(os.path.expanduser("~/Library/Preferences/Ableton/Live*/Log.txt")), key=os.path.getmtime)
|
||||||
|
path = None
|
||||||
|
if logs:
|
||||||
|
with open(logs[-1], "rb") as f:
|
||||||
|
text = f.read().decode("utf-8", "replace")
|
||||||
|
for m in re.finditer(r'info: Loading document "(.*)"', text):
|
||||||
|
if "/Templates/" not in m.group(1):
|
||||||
|
path = m.group(1)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def osc_pad(b):
|
||||||
|
return b + b"\0" * (4 - len(b) % 4)
|
||||||
|
|
||||||
|
|
||||||
|
def osc_msg(addr, *args):
|
||||||
|
tags, data = ",", b""
|
||||||
|
for a in args:
|
||||||
|
if isinstance(a, int):
|
||||||
|
tags, data = tags + "i", data + struct.pack(">i", a)
|
||||||
|
elif isinstance(a, float):
|
||||||
|
tags, data = tags + "f", data + struct.pack(">f", a)
|
||||||
|
else:
|
||||||
|
tags, data = tags + "s", data + osc_pad(str(a).encode())
|
||||||
|
return osc_pad(addr.encode()) + osc_pad(tags.encode()) + data
|
||||||
|
|
||||||
|
|
||||||
|
def osc_parse(b):
|
||||||
|
def string(i):
|
||||||
|
j = b.index(b"\0", i)
|
||||||
|
return b[i:j].decode("utf-8", "replace"), (j // 4 + 1) * 4
|
||||||
|
addr, i = string(0)
|
||||||
|
tags, i = string(i)
|
||||||
|
out = []
|
||||||
|
for t in tags[1:]:
|
||||||
|
if t == "i":
|
||||||
|
out.append(struct.unpack(">i", b[i:i + 4])[0]); i += 4
|
||||||
|
elif t == "f":
|
||||||
|
out.append(struct.unpack(">f", b[i:i + 4])[0]); i += 4
|
||||||
|
elif t == "s":
|
||||||
|
x, i = string(i); out.append(x)
|
||||||
|
elif t in "TF":
|
||||||
|
out.append(t == "T")
|
||||||
|
return addr, out
|
||||||
|
|
||||||
|
|
||||||
|
class Live:
|
||||||
|
"""Live on this Mac: AbletonOSC (with the status panel's commands, tools/abletonosc) and its menus
|
||||||
|
(System Events: osascript has Accessibility on the Tracks Mac)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
import socket
|
||||||
|
self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
self.s.bind(("127.0.0.1", 11001))
|
||||||
|
except OSError:
|
||||||
|
raise RuntimeError("OSC PORT BUSY")
|
||||||
|
self.s.settimeout(0.3)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.s.close()
|
||||||
|
|
||||||
|
def ask(self, addr, *args, wait=5.0):
|
||||||
|
import socket
|
||||||
|
self.s.sendto(osc_msg(addr, *args), ("127.0.0.1", 11000))
|
||||||
|
end = time.time() + wait
|
||||||
|
while time.time() < end:
|
||||||
|
try:
|
||||||
|
b, _ = self.s.recvfrom(65536)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
a, out = osc_parse(b)
|
||||||
|
if a == addr:
|
||||||
|
return out
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clip(self, i, name, beat):
|
||||||
|
"""(found, pitch, warping, file) of the arrangement clip at `beat` on the track."""
|
||||||
|
r = self.ask("/live/statuspanel/clip", i, name, float(beat))
|
||||||
|
return tuple(r[3:7]) if r and len(r) >= 7 else None
|
||||||
|
|
||||||
|
def menu(self, item, menu="Edit"):
|
||||||
|
"""Click a menu item of Live's; False when it is greyed out (nothing selected)."""
|
||||||
|
r = subprocess.run(["/usr/bin/osascript", "-e", 'with timeout of 20 seconds\n'
|
||||||
|
'tell application "System Events" to tell process "Live"\n'
|
||||||
|
'set frontmost to true\n'
|
||||||
|
'set m to menu item "%s" of menu "%s" of menu bar 1\n'
|
||||||
|
'if not (enabled of m) then return "off"\n'
|
||||||
|
'click m\nreturn "on"\nend tell\nend timeout' % (item, menu)],
|
||||||
|
capture_output=True, text=True, timeout=40)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError("LIVE MENU: " + (r.stderr.strip()[-40:] or "FAILED").upper())
|
||||||
|
return r.stdout.strip() == "on"
|
||||||
|
|
||||||
|
|
||||||
|
def live_app():
|
||||||
|
"""Live's app bundle (the running one, else the newest in /Applications)."""
|
||||||
|
r = subprocess.run(["/bin/ps", "-axo", "command"], capture_output=True, text=True)
|
||||||
|
m = re.search(r"^(/.*?\.app)/Contents/MacOS/Live$", r.stdout, re.M)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
apps = sorted(n for n in os.listdir("/Applications") if n.startswith("Ableton Live") and n.endswith(".app"))
|
||||||
|
return os.path.join("/Applications", apps[-1]) if apps else None
|
||||||
|
|
||||||
|
|
||||||
|
def open_in_live(live, path, todo, wait=180):
|
||||||
|
"""Open the set in Live (not while it plays); True once it is loaded."""
|
||||||
|
if live_set() == path:
|
||||||
|
return True
|
||||||
|
playing = live.ask("/live/song/get/is_playing", wait=3)
|
||||||
|
if playing and playing[0]:
|
||||||
|
raise RuntimeError("LIVE IS PLAYING")
|
||||||
|
app = live_app()
|
||||||
|
if not app:
|
||||||
|
raise RuntimeError("NO LIVE ON THIS MAC")
|
||||||
|
subprocess.run(["/usr/bin/open", "-a", app, path], capture_output=True, timeout=30)
|
||||||
|
end = time.time() + wait
|
||||||
|
while time.time() < end:
|
||||||
|
time.sleep(2)
|
||||||
|
if live_set() == path:
|
||||||
|
i, name, beat, _ = todo[0]
|
||||||
|
got = live.clip(i, name, beat)
|
||||||
|
if got and got[0]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def save_live(path, live):
|
||||||
|
before = os.path.getmtime(path)
|
||||||
|
live.menu("Save Live Set", "File")
|
||||||
|
for _ in range(120):
|
||||||
|
if os.path.getmtime(path) != before:
|
||||||
|
return True
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def consolidate(path, cfg, status, log, live=None):
|
||||||
|
"""Step 7 for one set: in Live, every pitched transposed clip consolidated (Edit > Consolidate renders it
|
||||||
|
with Complex Pro) and its warp turned off; an unpitched one put back in its own key, warp off. Saved."""
|
||||||
|
name = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
todo = pending(load(path), cfg.get("unpitched") or UNPITCHED)
|
||||||
|
if not todo:
|
||||||
|
return {"name": name, "consolidated": 0}
|
||||||
|
own = live is None
|
||||||
|
live = live or Live()
|
||||||
|
status.write(live=time.time()) # the panel hands AbletonOSC back to Companion after
|
||||||
|
try:
|
||||||
|
if live.ask("/live/song/get/tempo", wait=3) is None:
|
||||||
|
raise RuntimeError("LIVE NOT ANSWERING")
|
||||||
|
status.write(step="OPENING %s IN LIVE" % name.upper()[:14])
|
||||||
|
if not open_in_live(live, path, todo):
|
||||||
|
log.append("OPEN %s IN LIVE (SAVE THE SET THAT IS OPEN), HOLD START" % name.upper())
|
||||||
|
return {"name": name, "consolidated": 0, "left": len(todo)}
|
||||||
|
done, reset, missed = 0, 0, []
|
||||||
|
for n, (i, tn, beat, is_pitched) in enumerate(todo, 1):
|
||||||
|
status.write(step="CONSOLIDATING %d/%d" % (n, len(todo)), live=time.time())
|
||||||
|
got = live.clip(i, tn, beat)
|
||||||
|
if not got or not got[0] or not got[1]:
|
||||||
|
missed.append(stem_name(tn)) # changed in Live since it was saved
|
||||||
|
continue
|
||||||
|
if not is_pitched:
|
||||||
|
live.ask("/live/statuspanel/set_clip", i, tn, float(beat), 0, 0)
|
||||||
|
reset += 1
|
||||||
|
continue
|
||||||
|
live.ask("/live/statuspanel/select_clip", i, tn, float(beat))
|
||||||
|
time.sleep(0.3)
|
||||||
|
if not live.menu("Consolidate"):
|
||||||
|
missed.append(stem_name(tn))
|
||||||
|
continue
|
||||||
|
end = time.time() + cfg.get("consolidate_wait", 300)
|
||||||
|
while time.time() < end:
|
||||||
|
time.sleep(0.5)
|
||||||
|
now = live.clip(i, tn, beat)
|
||||||
|
if now and now[0] and now[1] == 0 and now[3] != got[3]:
|
||||||
|
live.ask("/live/statuspanel/set_clip", i, tn, float(beat), 0, 0)
|
||||||
|
done += 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
missed.append(stem_name(tn))
|
||||||
|
if done or reset:
|
||||||
|
status.write(step="SAVING %s" % name.upper()[:14])
|
||||||
|
if not save_live(path, live):
|
||||||
|
log.append("%s: LIVE DID NOT SAVE, SAVE IT IN LIVE" % name.upper())
|
||||||
|
if reset:
|
||||||
|
log.append("%s: %d UNPITCHED CLIPS PUT BACK IN THEIR KEY" % (name.upper(), reset))
|
||||||
|
for m in missed:
|
||||||
|
log.append("%s: %s NOT CONSOLIDATED" % (name.upper(), m))
|
||||||
|
return {"name": name, "consolidated": done, "reset": reset, "left": len(missed)}
|
||||||
|
finally:
|
||||||
|
status.write(live=time.time())
|
||||||
|
if own:
|
||||||
|
live.close()
|
||||||
|
|
||||||
|
|
||||||
# --- the job ----------------------------------------------------------------------------------
|
# --- the job ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
def projects_dir(cfg):
|
def projects_dir(cfg):
|
||||||
@@ -1283,12 +1513,9 @@ def run(cfg):
|
|||||||
status.data["results"].append({"name": name, "skipped": "THERE ALREADY"})
|
status.data["results"].append({"name": name, "skipped": "THERE ALREADY"})
|
||||||
continue
|
continue
|
||||||
todo.append(svc)
|
todo.append(svc)
|
||||||
if not todo:
|
|
||||||
status.write(state="done", step="NOTHING TO DO")
|
|
||||||
return
|
|
||||||
# 1. Last weekend's songs to Finished Songs (from the newest weekend set before the first new one).
|
# 1. Last weekend's songs to Finished Songs (from the newest weekend set before the first new one).
|
||||||
first = min(s["date"] for s in todo)
|
first = min(s["date"] for s in todo) if todo else None
|
||||||
prev = newest_set(site_dir, first, weekend_pattern(cfg))
|
prev = newest_set(site_dir, first, weekend_pattern(cfg)) if todo else None
|
||||||
if prev and cfg.get("save_songs", True):
|
if prev and cfg.get("save_songs", True):
|
||||||
status.write(step="SAVING SONGS")
|
status.write(step="SAVING SONGS")
|
||||||
saved = export_songs(prev, cfg, status, log)
|
saved = export_songs(prev, cfg, status, log)
|
||||||
@@ -1307,7 +1534,29 @@ def run(cfg):
|
|||||||
log.extend(res["notes"])
|
log.extend(res["notes"])
|
||||||
status.data["results"].append(res)
|
status.data["results"].append(res)
|
||||||
status.write()
|
status.write()
|
||||||
status.write(state="done", step="DONE")
|
# 7. In Live: the transposed clips consolidated, warp off (the nearest service's set is left open).
|
||||||
|
live = None
|
||||||
|
worked = bool(todo)
|
||||||
|
try:
|
||||||
|
for svc in sorted(cfg["services"], key=lambda s: s["date"], reverse=True) if cfg.get("consolidate", True) else []:
|
||||||
|
folder, name = target(cfg, svc)
|
||||||
|
d = os.path.join(folder, name)
|
||||||
|
als = [os.path.join(d, x) for x in os.listdir(d) if x.endswith(".als") and not x.startswith(".")] if os.path.isdir(d) else []
|
||||||
|
if not als or not pending(load(max(als, key=os.path.getmtime)), cfg.get("unpitched") or UNPITCHED):
|
||||||
|
continue
|
||||||
|
worked = True
|
||||||
|
try:
|
||||||
|
live = live or Live()
|
||||||
|
res = consolidate(max(als, key=os.path.getmtime), cfg, status, log, live)
|
||||||
|
except RuntimeError as e:
|
||||||
|
log.append("%s: CONSOLIDATE IN LIVE: %s" % (name.upper(), e))
|
||||||
|
continue
|
||||||
|
status.data["results"].append(res)
|
||||||
|
status.write()
|
||||||
|
finally:
|
||||||
|
if live:
|
||||||
|
live.close()
|
||||||
|
status.write(state="done", step="DONE" if worked else "NOTHING TO DO")
|
||||||
except Exception as e: # noqa: BLE001 - the key shows what stopped it
|
except Exception as e: # noqa: BLE001 - the key shows what stopped it
|
||||||
import traceback
|
import traceback
|
||||||
status.write(state="failed", step=str(e)[:60].upper(), trace=traceback.format_exc()[-2000:])
|
status.write(state="failed", step=str(e)[:60].upper(), trace=traceback.format_exc()[-2000:])
|
||||||
|
|||||||
+78
-11
@@ -1848,7 +1848,11 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
|||||||
lines["track_bundle"] = eval_track_bundle(rc, al)
|
lines["track_bundle"] = eval_track_bundle(rc, al)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
failed(["tracks", "mix_foh", "lights", "keys", "tune", "light_timing"] + (["track_bundle"] if rc.get("locations") else []), e)
|
failed(["tracks", "mix_foh", "lights", "keys", "tune", "light_timing"] + (["track_bundle"] if rc.get("locations") else []), e)
|
||||||
if rc.get("tracks_host") and rc.get("ableton_osc"):
|
if rc.get("tracks_host") and rc.get("ableton_osc") and start_in_live():
|
||||||
|
for k in ("routing", "ears"):
|
||||||
|
lines[k] = Line()
|
||||||
|
lines[k].info = "START IN LIVE"
|
||||||
|
elif rc.get("tracks_host") and rc.get("ableton_osc"):
|
||||||
try:
|
try:
|
||||||
lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]())
|
lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]())
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
@@ -1938,13 +1942,53 @@ SERVICES = {} # the services START builds, kept a quarter of an
|
|||||||
IS_LIVE = [lambda: False] # the Poller's "a service is on": the hold copies gigabytes, never during one
|
IS_LIVE = [lambda: False] # the Poller's "a service is on": the hold copies gigabytes, never during one
|
||||||
|
|
||||||
START_SURVEY = r'''
|
START_SURVEY = r'''
|
||||||
import json, os
|
import gzip, json, os, re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
P = json.loads(%r)
|
P = json.loads(%r)
|
||||||
out = {}
|
CACHE = os.path.expanduser("~/Library/Caches/status-panel-start-pending.json")
|
||||||
|
try:
|
||||||
|
cache = json.load(open(CACHE))
|
||||||
|
except Exception:
|
||||||
|
cache = {}
|
||||||
|
def transposed(path):
|
||||||
|
# {song group: clips still transposed} (consolidated ones are back at 0), by the set's mtime
|
||||||
|
key = "%%s|%%s" %% (path, os.path.getmtime(path))
|
||||||
|
if key not in cache:
|
||||||
|
ls = ET.parse(gzip.open(path)).getroot().find("LiveSet")
|
||||||
|
up = {}
|
||||||
|
for t in ls.find("Tracks"):
|
||||||
|
g = t.find("TrackGroupId")
|
||||||
|
up[t.get("Id")] = (t.find("Name/EffectiveName").get("Value"), g.get("Value") if g is not None else "-1")
|
||||||
|
def top(i):
|
||||||
|
while up.get(i, ("", "-1"))[1] in up:
|
||||||
|
i = up[i][1]
|
||||||
|
return up.get(i, ("", ""))[0]
|
||||||
|
got = {}
|
||||||
|
for t in ls.find("Tracks"):
|
||||||
|
for c in t.iter("AudioClip"):
|
||||||
|
if c.find("PitchCoarse") is not None and float(c.find("PitchCoarse").get("Value")) != 0:
|
||||||
|
song = top(t.get("Id"))
|
||||||
|
got[song] = got.get(song, 0) + 1
|
||||||
|
cache[key] = got
|
||||||
|
return cache[key]
|
||||||
|
out, keep = {}, {}
|
||||||
for s in P["services"]:
|
for s in P["services"]:
|
||||||
d = os.path.join(os.path.expanduser(P["projects"]), s["folder"], s["name"])
|
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)),
|
als = [os.path.join(d, n) for n in os.listdir(d) if n.endswith(".als") and not n.startswith(".")] if os.path.isdir(d) else []
|
||||||
"building": os.path.isdir(d + ".building")}
|
out[s["name"]] = {"exists": bool(als), "building": os.path.isdir(d + ".building")}
|
||||||
|
if als:
|
||||||
|
path = max(als, key=os.path.getmtime)
|
||||||
|
try:
|
||||||
|
out[s["name"]]["transposed"] = transposed(path)
|
||||||
|
k = "%%s|%%s" %% (path, os.path.getmtime(path))
|
||||||
|
keep[k] = cache[k]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
with open(CACHE, "w") as f:
|
||||||
|
json.dump(keep, f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
st = json.load(open(os.path.expanduser("~/Library/Caches/status-panel-start.json")))
|
st = json.load(open(os.path.expanduser("~/Library/Caches/status-panel-start.json")))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2092,7 +2136,9 @@ def start_params(rc, services, mode):
|
|||||||
"lighting_track": rc.get("lighting_track", "LIGHTING CUES"), "sections_track": rc.get("sections_track", "SECTIONS"),
|
"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"),
|
"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"),
|
"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}
|
"send_a": 1.0, "save_songs": rc.get("start_save_songs", True), "services": services,
|
||||||
|
"guide_color": rc.get("start_guide_color", 14), "unpitched": rc.get("start_unpitched") or None,
|
||||||
|
"consolidate": rc.get("start_consolidate", True)}
|
||||||
|
|
||||||
|
|
||||||
def start_script(params):
|
def start_script(params):
|
||||||
@@ -2103,8 +2149,21 @@ def start_script(params):
|
|||||||
|
|
||||||
|
|
||||||
def start_look(cfg, rc, services):
|
def start_look(cfg, rc, services):
|
||||||
return run_remote(cfg, rc["tracks_host"], START_SURVEY % json.dumps({"projects": rc.get("projects", ""), "services": [
|
look = 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)
|
{"folder": x["folder"], "name": x["name"]} for x in services]}), timeout=30)
|
||||||
|
st = look.get("status") or {}
|
||||||
|
LAST["start_status"] = st
|
||||||
|
# START used AbletonOSC from the Tracks Mac (consolidating): hand it back to Companion once it is done.
|
||||||
|
if st.get("live") and st.get("state") != "running" and LAST.get("start_handed") != st["live"]:
|
||||||
|
hand_back(cfg, rc["tracks_host"])
|
||||||
|
LAST["start_handed"] = st["live"]
|
||||||
|
return look
|
||||||
|
|
||||||
|
|
||||||
|
def start_in_live():
|
||||||
|
"""START is consolidating in Live just now (it holds AbletonOSC's answers on the Tracks Mac)."""
|
||||||
|
st = LAST.get("start_status") or {}
|
||||||
|
return st.get("state") == "running" and time.time() - (st.get("live") or 0) < 600
|
||||||
|
|
||||||
|
|
||||||
def eval_start(rc, services, look, now):
|
def eval_start(rc, services, look, now):
|
||||||
@@ -2128,14 +2187,22 @@ def eval_start(rc, services, look, now):
|
|||||||
if res.get("name") not in names:
|
if res.get("name") not in names:
|
||||||
continue
|
continue
|
||||||
for note in res.get("notes") or []:
|
for note in res.get("notes") or []:
|
||||||
m = re.match(r"TRANSPOSE (.+?) \S+>\S+", note)
|
|
||||||
n = re.match(r"NEW SONG (.+?):", note)
|
n = re.match(r"NEW SONG (.+?):", note)
|
||||||
if m:
|
if n:
|
||||||
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))
|
line.fault(WARN, "NEW SONG " + short(n.group(1), 12), "%s: %s" % (res["name"], note))
|
||||||
else:
|
else:
|
||||||
line.notes.append("%s: %s" % (res["name"], note))
|
line.notes.append("%s: %s" % (res["name"], note))
|
||||||
|
if not running:
|
||||||
|
# The workflow consolidates every transposed clip (and turns its warp off): a hold does it in Live.
|
||||||
|
for svc in services:
|
||||||
|
for song, n in sorted(((survey.get(svc["name"]) or {}).get("transposed") or {}).items()):
|
||||||
|
line.fault(WARN, "CONSOLIDATE " + short(song.upper(), 12),
|
||||||
|
"%s: %s has %d transposed clip%s not consolidated: hold START to consolidate them in Live"
|
||||||
|
% (svc["name"], song, n, "" if n == 1 else "s"))
|
||||||
|
if st.get("state") == "done" and now - (st.get("time") or 0) < 86400:
|
||||||
|
for note in st.get("log") or []:
|
||||||
|
if "CONSOLIDATE IN LIVE:" in note or note.startswith("OPEN ") or "NOT CONSOLIDATED" in note or "DID NOT SAVE" in note:
|
||||||
|
line.notes.append(note)
|
||||||
line.info = " ".join(x["label"] for x in services) or "NO SERVICES"
|
line.info = " ".join(x["label"] for x in services) or "NO SERVICES"
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|||||||
@@ -457,6 +457,12 @@ field("ready.start_save_songs", "bool", True, "START saves the previous weekend'
|
|||||||
"(a changed song parks the old folder as zSONG [KEY]).", "Ready")
|
"(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 "
|
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)
|
"groups, MIDI GROUP and RESI TRACKS).", "Ready", advanced=True)
|
||||||
|
field("ready.start_consolidate", "bool", True, "A hold of START also consolidates every transposed clip in Live and turns "
|
||||||
|
"its warp off (the workflow's last steps), opening the set in Live when another is open (never while Live plays).", "Ready")
|
||||||
|
field("ready.start_guide_color", "int", 14, "Live's clip color of the guide START uses when a song holds two (14: the "
|
||||||
|
"bright red one, not the light pink one).", "Ready", advanced=True)
|
||||||
|
field("ready.start_unpitched", "str", "", "A pattern for the stems START never transposes (empty: drums, percussion, aux "
|
||||||
|
"drums, shakers, tambourines, cymbals, claps, hits and a plain LOOP).", "Ready", advanced=True)
|
||||||
field("ready.pads_folder", "path", "", "The ambient pad files (\"D# - WARM - CHURCHFRONT PADS.wav\"); empty: PADS next to "
|
field("ready.pads_folder", "path", "", "The ambient pad files (\"D# - WARM - CHURCHFRONT PADS.wav\"); empty: PADS next to "
|
||||||
"Projects.", "Ready", advanced=True)
|
"Projects.", "Ready", advanced=True)
|
||||||
field("ready.sections_track", "str", "SECTIONS", "The arrangement marker track.", "Ready", advanced=True)
|
field("ready.sections_track", "str", "SECTIONS", "The arrangement marker track.", "Ready", advanced=True)
|
||||||
|
|||||||
+129
-6
@@ -90,8 +90,12 @@ class TestServices(unittest.TestCase):
|
|||||||
self.assertEqual([m for _, m, _ in line.faults], ["NO SET TUE 11/3"])
|
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}}
|
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": [
|
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)
|
{"name": "261101 NA Project", "notes": ["TRANSPOSE THE LORD IS HERE E>B", "NEW SONG GRACE: BUILD IT BY HAND"]}]}}, MONDAY + 60)
|
||||||
self.assertEqual([m for _, m, _ in line.faults], ["CONSOLIDATE THE LORD IS.", "NEW SONG GRACE"])
|
self.assertEqual([m for _, m, _ in line.faults], ["NEW SONG GRACE"]) # transposed and consolidated
|
||||||
|
left = {"261101 NA Project": {"exists": True, "transposed": {"The Lord Is Here": 22}},
|
||||||
|
"261103 NA First Tuesday Project": {"exists": True, "transposed": {}}}
|
||||||
|
line = rd.eval_start(RC, svc, {"survey": left, "status": None}, MONDAY)
|
||||||
|
self.assertEqual([m for _, m, _ in line.faults], ["CONSOLIDATE THE LORD IS."]) # still transposed in the set
|
||||||
line = rd.eval_start(RC, svc, {"survey": both, "status": None}, MONDAY)
|
line = rd.eval_start(RC, svc, {"survey": both, "status": None}, MONDAY)
|
||||||
self.assertEqual((line.faults, line.info), ([], "SUN 11/1 TUE 11/3"))
|
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)
|
line = rd.eval_start(RC, svc, {"survey": {}, "status": {"state": "running", "time": MONDAY, "step": "COPYING 3/40"}}, MONDAY + 5)
|
||||||
@@ -240,9 +244,15 @@ class TestBuild(unittest.TestCase):
|
|||||||
track("MidiTrack", 1, "2-MIDI", "0", midi_clip(1992, 2024, "INTRO") + midi_clip(2024, 2056, "VERSE 1")) +
|
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("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"),
|
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)) +
|
"Samples/Processed/Consolidate/Guide [2026-01-01 000000].wav", True)
|
||||||
|
.replace('<Name Value="Guide" />', '<Name Value="Guide" /><Color Value="14" />')) +
|
||||||
track("AudioTrack", 4, "5-Keys 1 [x]", "0", audio_clip(1992, 2368, "Keys 1", c("Keys [x].wav"), "Samples/Processed/Consolidate/Keys [x].wav",
|
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))
|
secs=313.3333333), ext) +
|
||||||
|
track("AudioTrack", 5, "6-Drums (Live)", "0", audio_clip(1992, 2368, "Drums (Live)", c("Keys [x].wav"),
|
||||||
|
"Samples/Processed/Consolidate/Keys [x].wav", secs=313.3333333)) +
|
||||||
|
track("AudioTrack", 6, "7-Guide [2026-07-06 092943]", "0", audio_clip(1992, 2300, "Pink guide", c("Guide [2026-01-01 000000].wav"),
|
||||||
|
"Samples/Processed/Consolidate/Guide [2026-01-01 000000].wav", True)
|
||||||
|
.replace('<Name Value="Pink guide" />', '<Name Value="Pink guide" /><Color Value="28" />')))
|
||||||
write_als(os.path.join(song, "GRACE.als"), live_set(song_tracks, [], 143))
|
write_als(os.path.join(song, "GRACE.als"), live_set(song_tracks, [], 143))
|
||||||
self.pads = os.path.join(d, "PADS")
|
self.pads = os.path.join(d, "PADS")
|
||||||
for k in ("E", "B", "D#"):
|
for k in ("E", "B", "D#"):
|
||||||
@@ -257,6 +267,7 @@ class TestBuild(unittest.TestCase):
|
|||||||
"keep_groups": ["MIDI GROUP", "RESI TRACKS"], "pad_track": "AMBIENT PADS", "tune_track": "WAVES TUNE",
|
"keep_groups": ["MIDI GROUP", "RESI TRACKS"], "pad_track": "AMBIENT PADS", "tune_track": "WAVES TUNE",
|
||||||
"lighting_track": "LIGHTING CUES", "sections_track": "SECTIONS", "guide_track": "+GUIDE",
|
"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,
|
"loopguide_track": "+LOOPGUIDE", "loop_cue_track": "Loop Cue", "tempo_track": "TEMPO", "send_a": 1.0,
|
||||||
|
"consolidate": False,
|
||||||
"services": [{"date": "261101", "folder": "NA", "name": "261101 NA Project", "label": "SUN 11/1",
|
"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},
|
"songs": [{"title": "Grace", "key": "E", "bpm": 72}, {"title": "Grace", "key": "B", "bpm": 72},
|
||||||
{"title": "Brand New", "key": "D", "bpm": 120}],
|
{"title": "Brand New", "key": "D", "bpm": 120}],
|
||||||
@@ -302,10 +313,14 @@ class TestBuild(unittest.TestCase):
|
|||||||
c = A.clips(keys)[0]
|
c = A.clips(keys)[0]
|
||||||
self.assertEqual((A.val(c, "IsWarped"), A.val(c, "WarpMode"), A.val(c, "ComplexProFormants"), A.val(c, "PitchCoarse")),
|
self.assertEqual((A.val(c, "IsWarped"), A.val(c, "WarpMode"), A.val(c, "ComplexProFormants"), A.val(c, "PitchCoarse")),
|
||||||
("true", "6", "0", "-5"))
|
("true", "6", "0", "-5"))
|
||||||
|
drums = [t for t in A.family(ls, g2) if A.tname(t) == "6-Drums (Live)"][0]
|
||||||
|
d = A.clips(drums)[0]
|
||||||
|
self.assertEqual((A.val(d, "IsWarped"), A.val(d, "WarpMode"), A.val(d, "PitchCoarse")), ("false", "0", "0")) # unpitched: as it was
|
||||||
|
self.assertEqual(sorted(x[1] for x in A.pending(root)), ["5-Keys 1 [x]"])
|
||||||
self.assertEqual(A.val(keys, "DeviceChain/Mixer/Sends/TrackSendHolder/Send/Manual"), "1")
|
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(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([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.val(x, "Name") for x in A.clips(by["+GUIDE"])], ["Guide", "Guide"]) # the red one, not the newer pink
|
||||||
self.assertEqual(A.clips(by["+LOOPGUIDE"]), [])
|
self.assertEqual(A.clips(by["+LOOPGUIDE"]), [])
|
||||||
fr = c.find("SampleRef/FileRef")
|
fr = c.find("SampleRef/FileRef")
|
||||||
self.assertEqual(A.val(fr, "RelativePath"), "Samples/Imported/GRACE [E]/Keys [x].wav")
|
self.assertEqual(A.val(fr, "RelativePath"), "Samples/Imported/GRACE [E]/Keys [x].wav")
|
||||||
@@ -315,7 +330,7 @@ class TestBuild(unittest.TestCase):
|
|||||||
self.assertEqual(tempo[0], (0.0, "72.00 BPM"))
|
self.assertEqual(tempo[0], (0.0, "72.00 BPM"))
|
||||||
self.assertIn((tempo[-1][0], "72.00 BPM"), tempo)
|
self.assertIn((tempo[-1][0], "72.00 BPM"), tempo)
|
||||||
notes = st["results"][0]["notes"]
|
notes = st["results"][0]["notes"]
|
||||||
self.assertIn("TRANSPOSE GRACE E>B: CONSOLIDATE IN LIVE", notes)
|
self.assertIn("TRANSPOSE GRACE E>B", notes)
|
||||||
self.assertIn("NEW SONG BRAND NEW: BUILD IT BY HAND", notes)
|
self.assertIn("NEW SONG BRAND NEW: BUILD IT BY HAND", notes)
|
||||||
self.assertIn("NO PAD IN D", 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.assertFalse([n for n in os.listdir(os.path.join(self.proj, "NA")) if n.endswith(".building")])
|
||||||
@@ -375,3 +390,111 @@ class TestBuild(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPitched(unittest.TestCase):
|
||||||
|
def test_unpitched_stems(self):
|
||||||
|
for n in ("22-Drums (Live)", "Aux Drums (Live)", "27-Perc", "PERC (LIVE) 2", "Percussion 1", "23-Shaker", "Shaker Loop_1",
|
||||||
|
"Tambourine 1", "Sus Cymbals", "Toms", "Timbales", "Claps", "Clap FX_1", "Kick FX_1", "Snaps_1", "Impact FX",
|
||||||
|
"Hit 1_1", "Boom FX", "Loop", "Loop 2", "Click Track", "119.00 BPM", "LAMB DRUM REF 91 BPM", "4-Guide [2026-01-01 000000]"):
|
||||||
|
self.assertFalse(A.pitched(n), n)
|
||||||
|
for n in ("5-Piano", "Sub Bass", "Synth Bass 2", "FX", "Sub Drop_1", "Riser FX", "Timpani", "Synth Loop", "Vox Loop",
|
||||||
|
"Bells", "6-Piano [2026-07-13 095209]", "Choir", "AG 1"):
|
||||||
|
self.assertTrue(A.pitched(n), n)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLive:
|
||||||
|
"""Live as AbletonOSC and the menus show it: one clip per track, consolidated when Edit > Consolidate is clicked."""
|
||||||
|
|
||||||
|
def __init__(self, todo, open_set=True):
|
||||||
|
self.clips = {(i, b): [1, -5, 1, "/Samples/Imported/%s.wav" % n] for i, n, b, _ in todo}
|
||||||
|
self.names = {i: n for i, n, _, _ in todo}
|
||||||
|
self.selected, self.calls = None, []
|
||||||
|
|
||||||
|
def ask(self, addr, *args, wait=5.0):
|
||||||
|
self.calls.append((addr,) + args)
|
||||||
|
if addr == "/live/song/get/tempo":
|
||||||
|
return [120.0]
|
||||||
|
if addr == "/live/statuspanel/select_clip":
|
||||||
|
self.selected = (args[0], round(args[2], 3))
|
||||||
|
return list(args) + [1]
|
||||||
|
if addr == "/live/statuspanel/set_clip":
|
||||||
|
c = self.clips[(args[0], round(args[2], 3))]
|
||||||
|
c[1], c[2] = args[3], args[4]
|
||||||
|
return list(args[:3]) + c[:3]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clip(self, i, name, beat):
|
||||||
|
return tuple(self.clips.get((i, round(beat, 3)), [0, 0, 0, ""]))
|
||||||
|
|
||||||
|
def menu(self, item, menu="Edit"):
|
||||||
|
self.calls.append((menu, item))
|
||||||
|
if item == "Consolidate" and self.selected:
|
||||||
|
c = self.clips[self.selected]
|
||||||
|
c[1], c[3] = 0, "/Samples/Processed/Consolidate/x.wav"
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsolidate(unittest.TestCase):
|
||||||
|
def test_consolidates_pitched_and_puts_unpitched_back(self):
|
||||||
|
d = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, d)
|
||||||
|
path = os.path.join(d, "261101 NA Project", "261101 NA Project.als")
|
||||||
|
clip = lambda n: audio_clip(1992, 2368, n, "/x.wav", warped=True).replace('<PitchCoarse Value="0" />', '<PitchCoarse Value="-5" />')
|
||||||
|
write_als(path, live_set(track("GroupTrack", 1, "FULL SURRENDER") + track("AudioTrack", 2, "6-Piano", "1", clip("Piano")) +
|
||||||
|
track("AudioTrack", 3, "26-Drums", "1", clip("Drums")) + track("AudioTrack", 4, "7-Bass", "1"), []))
|
||||||
|
todo = A.pending(A.load(path))
|
||||||
|
self.assertEqual(todo, [(1, "6-Piano", 1992.0, True), (2, "26-Drums", 1992.0, False)])
|
||||||
|
live = FakeLive(todo)
|
||||||
|
A.STATUS = os.path.join(d, "status.json")
|
||||||
|
status = A.Status({})
|
||||||
|
log = []
|
||||||
|
orig = A.live_set, A.save_live
|
||||||
|
A.live_set, A.save_live = (lambda: path), (lambda p, lv: True)
|
||||||
|
try:
|
||||||
|
res = A.consolidate(path, {}, status, log, live)
|
||||||
|
finally:
|
||||||
|
A.live_set, A.save_live = orig
|
||||||
|
self.assertEqual((res["consolidated"], res["reset"], res["left"]), (1, 1, 0))
|
||||||
|
self.assertEqual(live.clips[(1, 1992.0)], [1, 0, 0, "/Samples/Processed/Consolidate/x.wav"]) # consolidated, warp off
|
||||||
|
self.assertEqual(live.clips[(2, 1992.0)][:3], [1, 0, 0]) # drums: own key, not consolidated
|
||||||
|
self.assertEqual([c for c in live.calls if c[0] == "Edit"], [("Edit", "Consolidate")])
|
||||||
|
self.assertIn("261101 NA PROJECT: 1 UNPITCHED CLIPS PUT BACK IN THEIR KEY", log)
|
||||||
|
self.assertTrue(status.data.get("live"))
|
||||||
|
|
||||||
|
def test_a_clip_changed_in_live_is_left(self):
|
||||||
|
todo = [(1, "6-Piano", 1992.0, True)]
|
||||||
|
live = FakeLive(todo)
|
||||||
|
live.clips[(1, 1992.0)][0] = 0 # not there any more
|
||||||
|
d = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, d)
|
||||||
|
path = os.path.join(d, "s.als")
|
||||||
|
write_als(path, live_set(track("AudioTrack", 2, "6-Piano", "-1",
|
||||||
|
audio_clip(1992, 2368, "Piano", "/x.wav", True).replace('<PitchCoarse Value="0" />', '<PitchCoarse Value="2" />')), []))
|
||||||
|
A.STATUS = os.path.join(d, "status.json")
|
||||||
|
orig = A.live_set
|
||||||
|
A.live_set = lambda: path
|
||||||
|
try:
|
||||||
|
res = A.consolidate(path, {}, A.Status({}), [], live)
|
||||||
|
finally:
|
||||||
|
A.live_set = orig
|
||||||
|
self.assertEqual((res["consolidated"], res["left"]), (0, 1))
|
||||||
|
self.assertNotIn(("Edit", "Consolidate"), live.calls)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandBack(unittest.TestCase):
|
||||||
|
def test_hands_abletonosc_back_once_after_start_used_live(self):
|
||||||
|
sent = []
|
||||||
|
orig = rd.run_remote, rd.hand_back
|
||||||
|
rd.run_remote = lambda cfg, host, script, timeout=60: {"survey": {}, "status": {"state": "done", "live": 5.0}}
|
||||||
|
rd.hand_back = lambda cfg, host: sent.append(host)
|
||||||
|
rd.LAST.pop("start_handed", None)
|
||||||
|
try:
|
||||||
|
rd.start_look({}, RC, [])
|
||||||
|
rd.start_look({}, RC, [])
|
||||||
|
finally:
|
||||||
|
rd.run_remote, rd.hand_back = orig
|
||||||
|
self.assertEqual(sent, ["tracks"])
|
||||||
|
rd.LAST["start_status"] = {"state": "running", "live": __import__("time").time()}
|
||||||
|
self.assertTrue(rd.start_in_live())
|
||||||
|
rd.LAST.pop("start_status")
|
||||||
|
|||||||
@@ -4,9 +4,20 @@
|
|||||||
Moves the note of `pitch` that starts at arrangement `beat` on the arrangement MIDI clip of the track
|
Moves the note of `pitch` that starts at arrangement `beat` on the arrangement MIDI clip of the track
|
||||||
called `track name` so it starts at `new beat`, keeping where it ends (the next cue is not touched).
|
called `track name` so it starts at `new beat`, keeping where it ends (the next cue is not touched).
|
||||||
Answers (<track name>, <beat>, <pitch>, <moved: 0 or 1>). Undo in Live (Cmd+Z) puts it back.
|
Answers (<track name>, <beat>, <pitch>, <moved: 0 or 1>). Undo in Live (Cmd+Z) puts it back.
|
||||||
|
|
||||||
|
START's consolidating (ableton_start.py) finds an arrangement clip by <track index> (Live's, no returns),
|
||||||
|
<track name> (any track of that name when the index has moved) and the <beat> it starts at:
|
||||||
|
/live/statuspanel/clip <index> <name> <beat>
|
||||||
|
Answers (<index>, <name>, <beat>, <found>, <pitch_coarse>, <warping>, <file path>).
|
||||||
|
/live/statuspanel/select_clip <index> <name> <beat>
|
||||||
|
Selects the clip in the arrangement (Edit > Consolidate then works on it). Answers (..., <found>).
|
||||||
|
/live/statuspanel/set_clip <index> <name> <beat> <pitch_coarse> <warping>
|
||||||
|
Sets the clip's transposition, then its warp. Answers (..., <found>, <pitch_coarse>, <warping>).
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import Live
|
||||||
|
|
||||||
logger = logging.getLogger("abletonosc")
|
logger = logging.getLogger("abletonosc")
|
||||||
|
|
||||||
|
|
||||||
@@ -34,3 +45,53 @@ def register(handler):
|
|||||||
return name, beat, pitch, moved
|
return name, beat, pitch, moved
|
||||||
|
|
||||||
handler.osc_server.add_handler("/live/statuspanel/move_note", move_note)
|
handler.osc_server.add_handler("/live/statuspanel/move_note", move_note)
|
||||||
|
|
||||||
|
def find(params):
|
||||||
|
song = handler.song
|
||||||
|
i, name, beat = int(params[0]), str(params[1]).strip().upper(), float(params[2])
|
||||||
|
ts = list(song.tracks)
|
||||||
|
order = ([ts[i]] if 0 <= i < len(ts) and ts[i].name.strip().upper() == name else []) + \
|
||||||
|
[t for t in ts if t.name.strip().upper() == name]
|
||||||
|
for t in order:
|
||||||
|
try:
|
||||||
|
arr = t.arrangement_clips
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for c in arr:
|
||||||
|
if abs(c.start_time - beat) < 0.01:
|
||||||
|
return t, c
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def info(c):
|
||||||
|
if c is None:
|
||||||
|
return 0, 0, 0, ""
|
||||||
|
return 1, int(c.pitch_coarse), int(c.warping), str(c.file_path)
|
||||||
|
|
||||||
|
def clip(params):
|
||||||
|
_, c = find(params)
|
||||||
|
return (int(params[0]), str(params[1]), float(params[2])) + info(c)
|
||||||
|
|
||||||
|
def select_clip(params):
|
||||||
|
t, c = find(params)
|
||||||
|
if c is not None:
|
||||||
|
song = handler.song
|
||||||
|
view = Live.Application.get_application().view
|
||||||
|
view.show_view("Arranger")
|
||||||
|
view.focus_view("Arranger")
|
||||||
|
song.view.selected_track = t
|
||||||
|
song.view.detail_clip = c
|
||||||
|
view.focus_view("Arranger")
|
||||||
|
logger.info("statuspanel: select %s at %s: %s", params[1], params[2], c is not None)
|
||||||
|
return int(params[0]), str(params[1]), float(params[2]), int(c is not None)
|
||||||
|
|
||||||
|
def set_clip(params):
|
||||||
|
_, c = find(params)
|
||||||
|
if c is not None:
|
||||||
|
c.pitch_coarse = int(params[3])
|
||||||
|
c.warping = bool(int(params[4]))
|
||||||
|
logger.info("statuspanel: %s at %s: pitch %s warp %s", params[1], params[2], params[3], params[4])
|
||||||
|
return (int(params[0]), str(params[1]), float(params[2])) + info(c)[:3]
|
||||||
|
|
||||||
|
handler.osc_server.add_handler("/live/statuspanel/clip", clip)
|
||||||
|
handler.osc_server.add_handler("/live/statuspanel/select_clip", select_clip)
|
||||||
|
handler.osc_server.add_handler("/live/statuspanel/set_clip", set_clip)
|
||||||
|
|||||||
Reference in New Issue
Block a user