READY: LIGHT TIMING (flash and FX cues against the drum hits); auto-fix on for testing

- LIGHT TIMING: every blinder flash / FX cue is timed against the Drums (Live) stem under it
  (deactivated clips count) on the Tracks Mac; cues a strong hit is more than 25 ms away from are named
  with song and bar
- Settings Overview: "Auto-fix on for testing" for 15 min to 2 h, outside the hours and over a pause;
  the AUTO-FIX key says TESTING, and it goes back to the hours by itself

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:18:46 -04:00
co-authored by Claude Opus 5.5
parent f179df59f1
commit d4e4092a9d
9 changed files with 300 additions and 19 deletions
+161 -5
View File
@@ -14,6 +14,8 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p
routing every stem goes where this location's template says (Keys -> Ext. Out 5/6 Keys), read live
from Ableton over AbletonOSC, so tracks just dragged in count before the set is saved
ears no song stem's in-ear send (send A) is at -inf: dragged-in songs come in that way
light_timing every blinder flash and FX cue on the lighting track lands on the hit in the drum stem under it
(a hit a few ms before or after the cue, with the cue itself on a quiet spot, is named)
keys every song is in the key PCO gives it: the set's locator for it ("3- JEHOVAH - B"), the pad
clip under it ("B - WARM - CHURCHFRONT PADS") and a Finished Songs folder ("JEHOVAH [B]")
prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions
@@ -471,14 +473,41 @@ def summary(path):
pg = c.find("ProgramChange")
tune.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
c.find("Name").get("Value"), int(pg.get("Value")) if pg is not None else None])
cues, hit_src = [], []
for t in list(ls.find("Tracks")):
name = t.find("Name/EffectiveName").get("Value").strip()
if name.upper() == P["lighting_track"].strip().upper():
for c in t.findall(".//ArrangerAutomation/Events/MidiClip"):
if c.find("Disabled") is not None and c.find("Disabled").get("Value") == "true":
continue
start, end = float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value"))
offset = float(c.find("Loop/LoopStart").get("Value"))
for kt in c.iter("KeyTrack"):
k = int(kt.find("MidiKey").get("Value"))
if k not in P["hit_notes"]:
continue
for n in kt.iter("MidiNoteEvent"):
b = start + float(n.get("Time")) - offset
if n.get("IsEnabled") != "false" and start <= b < end:
cues.append([b, k])
stem = re.sub(r"^\s*\d+\s*-\s*", "", re.sub(r"\[.*?\]", " ", name.upper())).strip()
if t.tag == "AudioTrack" and any(stem.startswith(h.upper()) for h in P["hit_stems"]):
for c in t.findall(".//ArrangerAutomation/Events/AudioClip"):
# a deactivated clip counts: the drummer plays live, and the stem still says where the hits are
fr = c.find("SampleRef/FileRef")
rel = fr.find("RelativePath").get("Value") if fr.find("RelativePath") is not None else ""
absp = fr.find("Path").get("Value") if fr.find("Path") is not None else ""
f = os.path.join(os.path.dirname(path), rel) if rel and os.path.exists(os.path.join(os.path.dirname(path), rel)) else absp
hit_src.append([t.get("Id"), float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
f, c.find("IsWarped").get("Value") == "true", float(c.find("Loop/LoopStart").get("Value"))])
return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events},
"ltc": ltc, "pads": pads, "tune": tune}
"ltc": ltc, "pads": pads, "tune": tune, "cues": cues, "hit_src": hit_src}
out = {"live_set": live_set(), "bundles": {}}
path = newest_als(folder(P["site"]))
out["set"] = path
if path:
st = os.stat(path)
key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], 4] # 4: the summary's layout
key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], P["lighting_track"], P["hit_notes"], P["hit_stems"], 6]
cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json")
data = None
try:
@@ -498,6 +527,12 @@ if path:
out["mtime"] = st.st_mtime
out["summary"] = data
out["finished"] = {}
out["light_names"] = {}
llib = os.path.expanduser(P["light_library"]) if P["light_library"] else ""
for n in sorted(os.listdir(llib)) if llib and os.path.isdir(llib) else []:
m = re.match(r"^(\d+)\s+(.+)\.alc$", n)
if m:
out["light_names"][m.group(1)] = m.group(2)
lib = os.path.expanduser(P["tune_library"]) if P["tune_library"] else ""
out["tune_library"] = {}
for n in sorted(os.listdir(lib)) if lib and os.path.isdir(lib) else []:
@@ -522,7 +557,9 @@ def ableton_params(rc, plan):
"date": datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"), "locations": rc.get("locations", []),
"ltc_track": rc.get("ltc_track", "LTC"), "pad_track": rc.get("pad_track", "AMBIENT PADS"),
"finished": rc.get("finished_folders", []), "tune_track": rc.get("tune_track", "WAVES TUNE"),
"tune_library": rc.get("tune_library", "")}
"tune_library": rc.get("tune_library", ""), "lighting_track": rc.get("lighting_track", "LIGHTING CUES"),
"hit_notes": rc.get("light_hit_notes", list(range(30, 40))), "hit_stems": rc.get("hit_stems", ["DRUMS"]),
"light_library": rc.get("light_library", "")}
def ableton_look(cfg, rc, plan):
@@ -807,6 +844,118 @@ def mix_plan(rc, live):
return routing, ears, sorted(changes.values(), key=lambda change: change["index"])
# --- Lighting cue timing -------------------------------------------------------------------
HIT_SCRIPT = r"""
import aifc, audioop, json, wave
P = json.loads(%r)
FRAME = 0.005
def look(path, sec, span):
aiff = path.lower().endswith((".aif", ".aiff"))
f = aifc.open(path, "rb") if aiff else wave.open(path, "rb")
try:
sr, w, ch = f.getframerate(), f.getsampwidth(), f.getnchannels()
a = max(0, int((sec - span - 0.03) * sr))
f.setpos(a)
hop, n = int(FRAME * sr), int((2 * span + 0.03) / FRAME)
data = f.readframes(hop * (n + 1))
finally:
f.close()
if aiff:
data = audioop.byteswap(data, w) # AIFF is big-endian
size = hop * w * ch
rms = [audioop.rms(data[i * size:(i + 1) * size], w) for i in range(n)]
rise = [max(0, rms[i] - sum(rms[max(0, i - 4):i]) / max(1, min(4, i))) for i in range(n)]
times = [a / sr + i * FRAME for i in range(n)]
near = [i for i in range(n) if abs(times[i] - sec) <= span]
at = max([rise[i] for i in near if abs(times[i] - sec) <= 0.012] or [0])
best = max(near, key=lambda i: rise[i]) if near else None
return [round(times[best] - sec, 4), rise[best], at] if best is not None else None
out = []
for path, sec, span in P["cues"]:
try:
out.append(look(path, sec, span))
except Exception as e:
out.append(str(e)[:60])
print(json.dumps(out))
"""
HITS = {} # (set, its mtime) -> the hit list, so an unchanged set is not listened to again
def cue_times(rc, summary):
"""[(beat, note, song group, file, second in the file, search span)] for every flash and FX cue over a drum stem."""
groups = song_groups(summary)
owner = {t["id"]: g for g, members in groups.items() for t in members}
tempo = summary["tempo"]
out, seen, wanted = [], set(), set(rc.get("light_hit_notes", range(30, 40)))
for beat, note in sorted(summary.get("cues") or []):
if beat in seen or note not in wanted:
continue # a flash and an FX on the same 16th: one hit
src = next((h for h in summary.get("hit_src") or [] if h[1] <= beat < h[2] and not h[4]), None)
if not src:
continue
seen.add(beat)
sec = seconds_at(tempo, beat) - seconds_at(tempo, src[1]) + src[5]
sixteenth = seconds_at(tempo, beat + 0.25) - seconds_at(tempo, beat)
out.append((beat, note, owner.get(src[0], ""), src[3], sec, min(0.08, 0.45 * sixteenth)))
return out
def bar_of(beat):
"""Live's position: beat 1315.75 -> "329.4.4" (4/4)."""
return "%d.%d.%d" % (beat // 4 + 1, beat % 4 + 1, (beat % 1) * 4 + 1)
def eval_light_timing(rc, look, hits):
"""Cues whose hit is clearly a few ms off: the strongest hit near the cue is at least `light_tolerance_ms`
away, is a real hit for that song, and is clearly stronger than what is under the cue itself."""
line = Line()
if not look.get("set"):
line.fault(ERR, "NO SET", "no set to check")
return line
cues = cue_times(rc, look["summary"])
if not cues:
line.info = "NO CUES ON DRUMS"
return line
names = look.get("light_names") or {}
tol = rc.get("light_tolerance_ms", 25) / 1000.0
by_file = {}
for c, h in zip(cues, hits):
if isinstance(h, list):
by_file.setdefault(c[3], []).append(h[1])
typical = {f: sorted(v)[len(v) // 2] for f, v in by_file.items()}
off, broken = [], set()
for c, h in zip(cues, hits):
if not isinstance(h, list):
broken.add(os.path.basename(c[3]))
continue
dt, best, at = h
if abs(dt) > tol and best >= 0.3 * typical.get(c[3], 0) and best > 0 and (at == 0 or best / at >= 1.5):
off.append((c, dt))
said = ["%s bar %s: %s is %d ms %s the hit" % (song or "?", bar_of(beat), names.get(str(note), "note %d" % note),
abs(dt) * 1000, "before" if dt > 0 else "after")
for (beat, note, song, _, _, _), dt in off]
if off:
line.fault(WARN, "%d CUES OFF" % len(off) if len(off) > 1 else "CUE OFF %s" % bar_of(off[0][0][0]), said[0])
line.notes += said[1:]
for f in sorted(broken):
line.notes.append("could not read %s" % f)
line.info = "%d CUES ON TIME" % (len(cues) - len(off) - sum(1 for h in hits if not isinstance(h, list)))
return line
def light_hits(cfg, rc, look):
"""The hits under every cue, listened to on the Tracks Mac (once per saved set)."""
key = (look.get("set"), look.get("mtime"))
if key not in HITS:
cues = cue_times(rc, look["summary"])
hits = run_remote(cfg, rc["tracks_host"], HIT_SCRIPT % json.dumps({"cues": [[c[3], c[4], c[5]] for c in cues]}),
timeout=rc.get("remote_timeout", 90)) if cues else []
HITS.clear()
HITS[key] = hits
return HITS[key]
# --- Song keys --------------------------------------------------------------------------
NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
@@ -1440,6 +1589,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6),
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc),
hits=lambda al: light_hits(cfg, rc, al),
prop_bundle=lambda plan: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps(
{"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
src.update(sources or {})
@@ -1458,7 +1608,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
plan = None
failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")),
("lights", rc.get("tracks_host")), ("keys", rc.get("tracks_host")),
("tune", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
("tune", rc.get("tracks_host")), ("light_timing", rc.get("tracks_host")),
("prop", rc.get("prop_url")),
("routing", rc.get("ableton_osc")), ("ears", rc.get("ableton_osc")),
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
("lineup", rc.get("lineup_url")), ("mics", True),
@@ -1493,6 +1644,11 @@ def look(cfg, now=None, data_dir=None, sources=None):
lines.update(eval_ableton(rc, plan, al))
lines["keys"] = eval_keys(rc, plan, al)
lines["tune"] = eval_tune(rc, plan, al)
if rc.get("light_timing_check", True):
try:
lines["light_timing"] = eval_light_timing(rc, al, src["hits"](al) if al.get("set") else [])
except Exception as e: # noqa: BLE001 - a stem it cannot read must not hide the rest
failed(["light_timing"], e)
if rc.get("ltc_note", "LTC"):
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
na_songs = song_ltc(rc, plan, al)
@@ -1515,7 +1671,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
if rc.get("locations"):
lines["track_bundle"] = eval_track_bundle(rc, al)
except Exception as e: # noqa: BLE001
failed(["tracks", "mix_foh", "lights", "keys", "tune"] + (["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"):
try:
lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]())