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:
+161
-5
@@ -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"]())
|
||||
|
||||
Reference in New Issue
Block a user