LIGHT TIMING: the hold saves the set; what is under a cue includes the stem's lag

- After moving cues, the hold clicks File > Save Live Set in Live (System Events; osascript has
  Accessibility on the Tracks Mac), only when the set open in Live is the one checked
- "Under the cue" now looks from 10 ms before to 25 ms after it: a cue on a hit whose stem starts
  10-15 ms after the grid read as a quiet spot (Name Above All Names 737.1.1 was moved by mistake
  and has been put back)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:50:05 -04:00
co-authored by Claude Opus 5.5
parent 625d424bda
commit 11ac8aa8e0
4 changed files with 54 additions and 5 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ HyperDeck is recording). **STATUS PAGE** (bottom left) goes back.
| | col 1 | col 2 | col 3 | col 4 | col 5 | col 6 | col 7 | | | col 1 | col 2 | col 3 | col 4 | col 5 | col 6 | col 7 |
|--------|-------|-------|-------|-------|-------|-------|-------| |--------|-------|-------|-------|-------|-------|-------|-------|
| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE | | row 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). 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. The key then 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 | | | | | READY | | row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | READY |
+36 -2
View File
@@ -874,7 +874,8 @@ def look(path, sec, span):
return None return None
att = [attack(env, i) for i in range(len(env))] att = [attack(env, i) for i in range(len(env))]
best = max(near, key=lambda i: att[i]) best = max(near, key=lambda i: att[i])
at = max([att[i] for i in near if abs(t0 + i * FRAME) <= 0.010] or [0]) # what is under the cue: the stems' hits start up to ~15 ms after the grid, so look that far after it
at = max([att[i] for i in near if -0.010 <= t0 + i * FRAME <= 0.025] or [0])
# where that hit starts: the first frame that climbs halfway from the quiet before it to its peak # where that hit starts: the first frame that climbs halfway from the quiet before it to its peak
lo, hi = min(env[max(0, best - 8):best] or [0]), max(env[best:best + 4] or [0]) lo, hi = min(env[max(0, best - 8):best] or [0]), max(env[best:best + 4] or [0])
start = next((j for j in range(max(0, best - 8), best + 4) if env[j] >= lo + 0.5 * (hi - lo)), best) start = next((j for j in range(max(0, best - 8), best + 4) if env[j] >= lo + 0.5 * (hi - lo)), best)
@@ -1033,6 +1034,32 @@ print(json.dumps({"done": done}))
""" """
SAVE_SCRIPT = r"""
import json, os, subprocess, time
P = json.loads(%r)
before = os.path.getmtime(P["set"])
r = subprocess.run(["/usr/bin/osascript", "-e", 'with timeout of 15 seconds\n'
'tell application "System Events" to click menu item "Save Live Set" of menu "File" of menu bar 1 of process "Live"\n'
'end timeout'], capture_output=True, text=True, timeout=30)
saved = False
for _ in range(40):
if os.path.getmtime(P["set"]) != before:
saved = True
break
time.sleep(0.25)
print(json.dumps({"saved": saved, "error": (r.stderr or "").strip()[-120:]}))
"""
def save_set(cfg, rc, path):
"""File > Save Live Set in Live on the Tracks Mac (System Events; osascript has Accessibility there,
2026-10-22), and True once the set file on disk is newer."""
r = run_remote(cfg, rc["tracks_host"], SAVE_SCRIPT % json.dumps({"set": path}), timeout=60)
if not r.get("saved"):
log.warning("Live did not save %s: %s", os.path.basename(path), r.get("error") or "the file did not change")
return bool(r.get("saved"))
def fix_light_timing(cfg, rc): def fix_light_timing(cfg, rc):
"""A hold: move every off cue onto its hit in the open set (AbletonOSC's status panel command).""" """A hold: move every off cue onto its hit in the open set (AbletonOSC's status panel command)."""
light = LAST.get("light") or {} light = LAST.get("light") or {}
@@ -1050,6 +1077,13 @@ def fix_light_timing(cfg, rc):
if moved: if moved:
MOVED.setdefault(light["key"], []) MOVED.setdefault(light["key"], [])
MOVED[light["key"]] = sorted(set(MOVED[light["key"]]) | set(moved)) MOVED[light["key"]] = sorted(set(MOVED[light["key"]]) | set(moved))
# Saved straight away, but only when the set open in Live is the one that was checked.
live, path = light.get("live_set"), light["key"][0]
if live and os.path.basename(live) == os.path.basename(path) and rc.get("light_autosave", True):
if save_set(cfg, rc, path):
log.info("FIX lighting cues: %s saved", os.path.basename(path))
else:
return "MOVED, SAVE BY HAND"
log.info("FIX lighting cues moved onto the hit in Live: %s%s", ", ".join(bar_of(b) for b in moved) or "none", log.info("FIX lighting cues moved onto the hit in Live: %s%s", ", ".join(bar_of(b) for b in moved) or "none",
" (not found in the open set: %s)" % ", ".join(bar_of(b) for b, _, n in r["done"] if not n) " (not found in the open set: %s)" % ", ".join(bar_of(b) for b, _, n in r["done"] if not n)
if any(not n for _, _, n in r["done"]) else "") if any(not n for _, _, n in r["done"]) else "")
@@ -1750,7 +1784,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
lines["light_timing"] = eval_light_timing(rc, al, hits) lines["light_timing"] = eval_light_timing(rc, al, hits)
if al.get("set"): if al.get("set"):
cues = cue_times(rc, al["summary"]) cues = cue_times(rc, al["summary"])
LAST["light"] = {"key": (al["set"], al.get("mtime")), "summary": al["summary"], LAST["light"] = {"key": (al["set"], al.get("mtime")), "summary": al["summary"], "live_set": al.get("live_set"),
"off": [(c, dt) for c, dt in off_cues(rc, cues, hits) "off": [(c, dt) for c, dt in off_cues(rc, cues, hits)
if c[0] not in set(MOVED.get((al["set"], al.get("mtime"))) or [])]} if c[0] not in set(MOVED.get((al["set"], al.get("mtime"))) or [])]}
except Exception as e: # noqa: BLE001 - a stem it cannot read must not hide the rest except Exception as e: # noqa: BLE001 - a stem it cannot read must not hide the rest
+2
View File
@@ -405,6 +405,8 @@ field("ready.light_hit_notes", "list", list(range(30, 40)), "The lighting notes
field("ready.hit_stems", "list", ["DRUMS"], "Stems whose hits the cues are timed against (names starting with these: " field("ready.hit_stems", "list", ["DRUMS"], "Stems whose hits the cues are timed against (names starting with these: "
"\"Drums (Live)\").", "Ready") "\"Drums (Live)\").", "Ready")
field("ready.light_tolerance_ms", "int", 25, "How far off a cue may be before it is named.", "Ready", lo=10, hi=100) field("ready.light_tolerance_ms", "int", 25, "How far off a cue may be before it is named.", "Ready", lo=10, hi=100)
field("ready.light_autosave", "bool", True, "After LIGHT TIMING's hold moves cues, save the set in Live (File > Save "
"Live Set, when the set open in Live is this weekend's).", "Ready")
field("ready.light_library", "path", "", "The folder of lighting cue clips (\"30 Blinder Flash 1.alc\"), for the " field("ready.light_library", "path", "", "The folder of lighting cue clips (\"30 Blinder Flash 1.alc\"), for the "
"cues' names.", "Ready", "cues' names.", "Ready",
example="~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI") example="~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI")
+15 -2
View File
@@ -495,11 +495,13 @@ class TestLightTiming(unittest.TestCase):
self.assertEqual([(b, n) for b, n, _ in moves], [(104, 30), (104, 36), (108, 31)]) self.assertEqual([(b, n) for b, n, _ in moves], [(104, 30), (104, 36), (108, 31)])
self.assertEqual(moves[0][2], 103.75) self.assertEqual(moves[0][2], 103.75)
self.assertAlmostEqual(moves[2][2], 108 + 0.047 * 151 / 60, places=3) self.assertAlmostEqual(moves[2][2], 108 + 0.047 * 151 / 60, places=3)
rd.LAST["light"] = {"key": (al["set"], al["mtime"]), "summary": al["summary"], "off": off} rd.LAST["light"] = {"key": (al["set"], al["mtime"]), "summary": al["summary"], "off": off, "live_set": "/elsewhere/261011.als"}
sent = [] sent = []
def fake_remote(cfg, host, script, timeout=60): def fake_remote(cfg, host, script, timeout=60):
sent.append(json.loads(script.split("P = json.loads(", 1)[1].split(")\n", 1)[0].strip()[1:-1])) sent.append(json.loads(script.split("P = json.loads(", 1)[1].split(")\n", 1)[0].strip()[1:-1]))
if "Save Live Set" in script:
return {"saved": True}
return {"done": [[b, n, 1] for b, n, _ in sent[-1]["moves"]]} return {"done": [[b, n, 1] for b, n, _ in sent[-1]["moves"]]}
real = (rd.run_remote, rd.hand_back) real = (rd.run_remote, rd.hand_back)
rd.run_remote, rd.hand_back = fake_remote, lambda cfg, host: None rd.run_remote, rd.hand_back = fake_remote, lambda cfg, host: None
@@ -508,9 +510,20 @@ class TestLightTiming(unittest.TestCase):
finally: finally:
rd.run_remote, rd.hand_back = real rd.run_remote, rd.hand_back = real
self.assertEqual(sent[0]["track"], "LIGHTING CUES") self.assertEqual(sent[0]["track"], "LIGHTING CUES")
# until the set is saved, the moved cues are not named again: the key asks for a save instead # another set is open in Live: nothing is saved, and until the set is saved the key asks for a save
self.assertEqual(len(sent), 1)
line = rd.eval_light_timing(RC, al, hits) line = rd.eval_light_timing(RC, al, hits)
self.assertEqual(msgs(line), ["SAVE SET (2 MOVED)"]) self.assertEqual(msgs(line), ["SAVE SET (2 MOVED)"])
# this weekend's set open in Live: moved, then saved straight away
rd.MOVED.clear()
rd.LAST["light"]["live_set"] = al["set"]
rd.run_remote, rd.hand_back = fake_remote, lambda cfg, host: None
try:
self.assertTrue(rd.run_action(CONFIG, "light_timing"))
finally:
rd.run_remote, rd.hand_back = real
self.assertEqual(sent[-1], {"set": al["set"]})
compile(rd.SAVE_SCRIPT % json.dumps({"set": "/x.als"}), "remote", "exec")
def test_bars_as_live_shows_them(self): def test_bars_as_live_shows_them(self):
self.assertEqual(rd.bar_of(1315.75), "329.4.4") self.assertEqual(rd.bar_of(1315.75), "329.4.4")