LIGHT TIMING: the real offset up to a full 16th, no flag on a cue already on a solid hit, hold moves the cues
- Detection in 2.5 ms steps; where a hit starts is halfway up its rise; each song's cues on solid hits set its "on time"; a cue already on a solid hit is never named; "about a 16th" when it is - Hold: moves the off cues onto their hit in the open set through a status panel command added to AbletonOSC (tools/abletonosc), keeping each cue's end; the key asks for a save until the set is saved Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|--------|-------|-------|-------|-------|-------|-------|-------|
|
||||
| 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 | | |
|
||||
| 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 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | |
|
||||
| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | READY |
|
||||
|
||||
|
||||
+8
-1
@@ -503,6 +503,13 @@
|
||||
"kind": "ready",
|
||||
"label": "put Ableton's stems back where the routing template says and their in-ear send (A) to 0 dB",
|
||||
"short": "MIX"
|
||||
},
|
||||
"ready_light_timing": {
|
||||
"action": "light_timing",
|
||||
"kind": "ready",
|
||||
"never_auto": true,
|
||||
"short": "CUES",
|
||||
"label": "move the lighting cues that are off onto the drum hit in the open Ableton set (then save the set)"
|
||||
}
|
||||
},
|
||||
"flap": {
|
||||
@@ -1412,7 +1419,7 @@
|
||||
{
|
||||
"type": "ready",
|
||||
"item": "light_timing",
|
||||
"fix": "ready_recheck"
|
||||
"fix": "ready_light_timing"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+145
-37
@@ -849,28 +849,36 @@ def mix_plan(rc, live):
|
||||
HIT_SCRIPT = r"""
|
||||
import aifc, audioop, json, wave
|
||||
P = json.loads(%r)
|
||||
FRAME = 0.005
|
||||
FRAME = 0.0025
|
||||
def attack(env, i):
|
||||
# how far the drums climb within 10 ms of frame i, from the quietest point in the 20 ms before it
|
||||
after = max(env[i:i + 4] or [0])
|
||||
return max(0, after - min(env[max(0, i - 8):i] or [after]))
|
||||
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))
|
||||
a = max(0, int((sec - span - 0.05) * sr))
|
||||
f.setpos(a)
|
||||
hop, n = int(FRAME * sr), int((2 * span + 0.03) / FRAME)
|
||||
data = f.readframes(hop * (n + 1))
|
||||
data = f.readframes(int((2 * span + 0.1) * sr))
|
||||
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
|
||||
hop = int(FRAME * sr) * w * ch
|
||||
env = [audioop.rms(data[i:i + hop], w) for i in range(0, len(data) - hop + 1, hop)]
|
||||
t0 = a / sr - sec
|
||||
near = [i for i in range(len(env)) if abs(t0 + i * FRAME) <= span]
|
||||
if not near:
|
||||
return None
|
||||
att = [attack(env, i) for i in range(len(env))]
|
||||
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])
|
||||
# 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])
|
||||
start = next((j for j in range(max(0, best - 8), best + 4) if env[j] >= lo + 0.5 * (hi - lo)), best)
|
||||
return [round(t0 + start * FRAME, 4), att[best], at]
|
||||
out = []
|
||||
for path, sec, span in P["cues"]:
|
||||
try:
|
||||
@@ -880,10 +888,12 @@ for path, sec, span in P["cues"]:
|
||||
print(json.dumps(out))
|
||||
"""
|
||||
HITS = {} # (set, its mtime) -> the hit list, so an unchanged set is not listened to again
|
||||
MOVED = {} # (set, its mtime) -> beats a hold moved in Live, until the set is saved
|
||||
|
||||
|
||||
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."""
|
||||
"""[(beat, note, song group, file, second in the file, search span, a 16th in seconds)] for every flash and FX
|
||||
cue over a drum stem. The search reaches out to almost a full 16th either way."""
|
||||
groups = song_groups(summary)
|
||||
owner = {t["id"]: g for g, members in groups.items() for t in members}
|
||||
tempo = summary["tempo"]
|
||||
@@ -897,7 +907,7 @@ def cue_times(rc, summary):
|
||||
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)))
|
||||
out.append((beat, note, owner.get(src[0], ""), src[3], sec, min(0.95 * sixteenth, 0.25), sixteenth))
|
||||
return out
|
||||
|
||||
|
||||
@@ -906,9 +916,35 @@ def bar_of(beat):
|
||||
return "%d.%d.%d" % (beat // 4 + 1, beat % 4 + 1, (beat % 1) * 4 + 1)
|
||||
|
||||
|
||||
def off_cues(rc, cues, hits):
|
||||
"""[(cue, ms off against the song's own cues on solid hits)] for cues on a quiet spot with a strong hit near.
|
||||
A cue that already sits on a solid hit is never named, even with a stronger hit close by (a double hit)."""
|
||||
tol = rc.get("light_tolerance_ms", 25) / 1000.0
|
||||
strengths = {}
|
||||
for c, h in zip(cues, hits):
|
||||
if isinstance(h, list):
|
||||
strengths.setdefault(c[3], []).append(h[1])
|
||||
typical = {f: sorted(v)[len(v) // 2] for f, v in strengths.items()}
|
||||
solid = lambda c, h: h[2] >= 0.5 * typical.get(c[3], 0) or (h[1] > 0 and h[2] >= 0.6 * h[1])
|
||||
# each song's stem starts its hits a few ms after the grid: what its cues on solid hits show is "on time"
|
||||
base = {}
|
||||
for c, h in zip(cues, hits):
|
||||
if isinstance(h, list) and solid(c, h):
|
||||
base.setdefault(c[2], []).append(h[0])
|
||||
base = {song: sorted(v)[len(v) // 2] for song, v in base.items()}
|
||||
out = []
|
||||
for c, h in zip(cues, hits):
|
||||
if not isinstance(h, list):
|
||||
continue
|
||||
dt = h[0] - base.get(c[2], 0.0)
|
||||
if abs(dt) > tol and h[1] >= 0.4 * typical.get(c[3], 0) and h[1] > 0 and not solid(c, h):
|
||||
out.append((c, dt))
|
||||
return out
|
||||
|
||||
|
||||
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."""
|
||||
"""Cues whose hit is clearly off (see off_cues), named with song and bar; cues a hold moved in Live
|
||||
count as done until the set is saved and listened to again."""
|
||||
line = Line()
|
||||
if not look.get("set"):
|
||||
line.fault(ERR, "NO SET", "no set to check")
|
||||
@@ -918,29 +954,23 @@ def eval_light_timing(rc, look, hits):
|
||||
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]
|
||||
moved = set(MOVED.get((look.get("set"), look.get("mtime"))) or [])
|
||||
off = [(c, dt) for c, dt in off_cues(rc, cues, hits) if c[0] not in moved]
|
||||
said = []
|
||||
for (beat, note, song, _, _, _, sixteenth), dt in off:
|
||||
how = "%d ms %s the hit" % (abs(dt) * 1000, "before" if dt > 0 else "after")
|
||||
if abs(dt) >= 0.75 * sixteenth:
|
||||
how += " (about a 16th)"
|
||||
said.append("%s bar %s: %s is %s" % (song or "?", bar_of(beat), names.get(str(note), "note %d" % note), how))
|
||||
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)))
|
||||
if moved:
|
||||
line.fault(WARN, "SAVE SET (%d MOVED)" % len(moved), "a hold moved %d cue%s onto the hit in Live: save the set "
|
||||
"(Cmd+S), or Cmd+Z in Live to take it back" % (len(moved), "" if len(moved) == 1 else "s"))
|
||||
broken = sorted({os.path.basename(c[3]) for c, h in zip(cues, hits) if not isinstance(h, list)})
|
||||
line.notes += ["could not read %s" % f for f in broken]
|
||||
line.info = "%d CUES ON TIME" % (len(cues) - len(off) - len(moved) - sum(1 for h in hits if not isinstance(h, list)))
|
||||
return line
|
||||
|
||||
|
||||
@@ -956,6 +986,76 @@ def light_hits(cfg, rc, look):
|
||||
return HITS[key]
|
||||
|
||||
|
||||
def moves(rc, summary, off):
|
||||
"""[(beat, note, new beat)] that put each off cue (every flash/FX note on that 16th) onto its hit, on the
|
||||
16th grid when the hit is within 10 ms of it."""
|
||||
out = []
|
||||
wanted = set(rc.get("light_hit_notes", range(30, 40)))
|
||||
for c, dt in off:
|
||||
beat, sixteenth = c[0], c[6]
|
||||
new = beat + dt * 0.25 / sixteenth
|
||||
grid = round(new * 4) / 4
|
||||
if abs(grid - new) * 4 * sixteenth <= 0.010:
|
||||
new = grid
|
||||
for b, note in summary.get("cues") or []:
|
||||
if b == beat and note in wanted:
|
||||
out.append((beat, note, round(new, 4)))
|
||||
return out
|
||||
|
||||
|
||||
MOVE_SCRIPT = r"""
|
||||
import json, socket, struct, time
|
||||
P = json.loads(%r)
|
||||
def pad(b):
|
||||
return b + b"\0" * (4 - len(b) %% 4)
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.bind(("127.0.0.1", 11001))
|
||||
except OSError:
|
||||
print(json.dumps({"error": "OSC PORT BUSY"})); raise SystemExit
|
||||
s.settimeout(0.5)
|
||||
done = []
|
||||
for beat, note, new in P["moves"]:
|
||||
s.sendto(pad(b"/live/statuspanel/move_note") + pad(b",sfif") + pad(P["track"].encode()) + struct.pack(">fif", beat, note, new),
|
||||
("127.0.0.1", 11000))
|
||||
end = time.time() + 3
|
||||
while time.time() < end:
|
||||
try:
|
||||
b, _ = s.recvfrom(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
if b.startswith(b"/live/statuspanel/move_note"):
|
||||
done.append([beat, note, struct.unpack(">i", b[-4:])[0]])
|
||||
break
|
||||
else:
|
||||
print(json.dumps({"error": "NO MOVE COMMAND"})); raise SystemExit
|
||||
print(json.dumps({"done": done}))
|
||||
"""
|
||||
|
||||
|
||||
def fix_light_timing(cfg, rc):
|
||||
"""A hold: move every off cue onto its hit in the open set (AbletonOSC's status panel command)."""
|
||||
light = LAST.get("light") or {}
|
||||
if not light.get("off"):
|
||||
return True
|
||||
todo = moves(rc, light["summary"], light["off"])
|
||||
try:
|
||||
r = run_remote(cfg, rc["tracks_host"], MOVE_SCRIPT % json.dumps(
|
||||
{"track": rc.get("lighting_track", "LIGHTING CUES"), "moves": todo}), timeout=60)
|
||||
finally:
|
||||
hand_back(cfg, rc["tracks_host"])
|
||||
if r.get("error"):
|
||||
return r["error"]
|
||||
moved = sorted({b for b, _, n in r["done"] if n})
|
||||
if moved:
|
||||
MOVED.setdefault(light["key"], [])
|
||||
MOVED[light["key"]] = sorted(set(MOVED[light["key"]]) | set(moved))
|
||||
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)
|
||||
if any(not n for _, _, n in r["done"]) else "")
|
||||
return True if moved else "NOT IN OPEN SET"
|
||||
|
||||
|
||||
# --- Song keys --------------------------------------------------------------------------
|
||||
|
||||
NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||
@@ -1646,7 +1746,13 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
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 [])
|
||||
hits = src["hits"](al) if al.get("set") else []
|
||||
lines["light_timing"] = eval_light_timing(rc, al, hits)
|
||||
if al.get("set"):
|
||||
cues = cue_times(rc, al["summary"])
|
||||
LAST["light"] = {"key": (al["set"], al.get("mtime")), "summary": al["summary"],
|
||||
"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 [])]}
|
||||
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"):
|
||||
@@ -1803,6 +1909,8 @@ def run_action(cfg, action, chk=None, sleep=time.sleep):
|
||||
return "NO SET TO COPY"
|
||||
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
|
||||
return True
|
||||
if action == "light_timing":
|
||||
return fix_light_timing(cfg, rc)
|
||||
if action == "ableton_mix":
|
||||
_, _, changes = mix_plan(rc, live_look(cfg, rc))
|
||||
if not changes:
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -819,7 +819,7 @@ def check_ready_manual(chk, snap, ctx):
|
||||
# What readiness.py looks at (check type "ready") and what a "ready" fix can do.
|
||||
READY_ITEMS = ("tracks", "mix_foh", "lights", "light_timing", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics",
|
||||
"track_bundle", "prop_bundle")
|
||||
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix")
|
||||
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix", "light_timing")
|
||||
|
||||
CHECKS = {
|
||||
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
|
||||
|
||||
+48
-11
@@ -445,43 +445,80 @@ def with_cues(al=None):
|
||||
|
||||
|
||||
class TestLightTiming(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
rd.MOVED.clear()
|
||||
rd.LAST.pop("light", None)
|
||||
|
||||
def test_cue_times_in_the_stem(self):
|
||||
cues = rd.cue_times(RC, with_cues()["summary"])
|
||||
self.assertEqual([(c[0], c[1], c[2]) for c in cues], [(104, 30, "CAN'T HELP BUT PRAISE"), (108, 31, "CAN'T HELP BUT PRAISE"),
|
||||
(112, 35, "CAN'T HELP BUT PRAISE"), (116, 30, "CAN'T HELP BUT PRAISE")])
|
||||
tempo = with_cues()["summary"]["tempo"]
|
||||
self.assertAlmostEqual(cues[0][4], rd.seconds_at(tempo, 104) - rd.seconds_at(tempo, 100))
|
||||
self.assertAlmostEqual(cues[0][5], 0.45 * 60 / 151 / 4, places=4) # under half a 16th at 151 BPM
|
||||
self.assertAlmostEqual(cues[0][6], 60 / 151 / 4, places=4) # a 16th at 151 BPM
|
||||
self.assertAlmostEqual(cues[0][5], 0.95 * 60 / 151 / 4, places=4) # searched out to almost a full 16th
|
||||
|
||||
def test_off_cues_are_named(self):
|
||||
# [hit - cue (s), the hit's strength, strength under the cue]
|
||||
hits = [[0.005, 5000, 5000], # on time
|
||||
[0.037, 5200, 2000], # 37 ms early, a strong hit, little under the cue
|
||||
# [where the hit starts against the cue (s), the hit's strength, the strength right under the cue]
|
||||
hits = [[0.010, 5000, 5000], # on a solid hit: this song's cues sit 10-12 ms before their hits
|
||||
[0.047, 5200, 1000], # on a quiet spot, a strong hit 35 ms (against the others) later
|
||||
[-0.053, 900, 300], # off, but a weak hit for this song: left alone
|
||||
[0.040, 5100, 4800]] # off, but the cue already sits on a hit as strong: left alone
|
||||
[0.050, 5100, 4800]] # already on a solid hit (a double hit follows): left alone
|
||||
hits[3][0] = 0.012
|
||||
line = rd.eval_light_timing(RC, with_cues(), hits)
|
||||
self.assertEqual(msgs(line), ["CUE OFF 28.1.1"])
|
||||
self.assertEqual(line.as_dict()["details"], ["CAN'T HELP BUT PRAISE bar 28.1.1: Blinder Flash 2 is 37 ms before the hit"])
|
||||
self.assertEqual(line.as_dict()["details"], ["CAN'T HELP BUT PRAISE bar 28.1.1: Blinder Flash 2 is 35 ms before the hit"])
|
||||
self.assertEqual(line.info, "3 CUES ON TIME")
|
||||
|
||||
def test_several_off(self):
|
||||
hits = [[-0.04, 5000, 0], [0.037, 5200, 2000], [0.0, 5000, 5000], [0.0, 5000, 5000]]
|
||||
def test_a_double_hit_is_not_named(self):
|
||||
hits = [[0.0, 5000, 5000], [0.060, 6000, 3100], [0.0, 5000, 5000], [0.0, 5000, 5000]]
|
||||
self.assertEqual(msgs(rd.eval_light_timing(RC, with_cues(), hits)), [])
|
||||
|
||||
def test_a_16th_late(self):
|
||||
hits = [[-0.09, 5000, 300], [0.037, 5200, 1000], [0.0, 5000, 5000], [0.0, 5000, 5000]]
|
||||
line = rd.eval_light_timing(RC, with_cues(), hits)
|
||||
self.assertEqual(msgs(line), ["2 CUES OFF"])
|
||||
self.assertEqual(len(line.as_dict()["details"]), 2)
|
||||
self.assertIn("Blinder Flash 1 is 40 ms after the hit", line.as_dict()["details"][0])
|
||||
self.assertIn("Blinder Flash 1 is 90 ms after the hit (about a 16th)", line.as_dict()["details"][0])
|
||||
|
||||
def test_a_stem_it_cannot_read(self):
|
||||
line = rd.eval_light_timing(RC, with_cues(), ["unknown format: 3"] * 4)
|
||||
self.assertEqual(msgs(line), [])
|
||||
self.assertEqual(line.as_dict()["details"], ["could not read Drums (Live).wav"])
|
||||
|
||||
def test_the_hold_moves_the_notes_onto_the_hit(self):
|
||||
al = with_cues()
|
||||
hits = [[-0.09, 5000, 300], [0.047, 5200, 1000], [0.0, 5000, 5000], [0.0, 5000, 5000]]
|
||||
cues = rd.cue_times(RC, al["summary"])
|
||||
off = rd.off_cues(RC, cues, hits)
|
||||
# 104 (Flash 1 and FX 2 on the same 16th) goes back a 16th onto the hit; 108 a little later, off the grid
|
||||
moves = rd.moves(RC, al["summary"], off)
|
||||
self.assertEqual([(b, n) for b, n, _ in moves], [(104, 30), (104, 36), (108, 31)])
|
||||
self.assertEqual(moves[0][2], 103.75)
|
||||
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}
|
||||
sent = []
|
||||
|
||||
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]))
|
||||
return {"done": [[b, n, 1] for b, n, _ in sent[-1]["moves"]]}
|
||||
real = (rd.run_remote, rd.hand_back)
|
||||
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[0]["track"], "LIGHTING CUES")
|
||||
# until the set is saved, the moved cues are not named again: the key asks for a save instead
|
||||
line = rd.eval_light_timing(RC, al, hits)
|
||||
self.assertEqual(msgs(line), ["SAVE SET (2 MOVED)"])
|
||||
|
||||
def test_bars_as_live_shows_them(self):
|
||||
self.assertEqual(rd.bar_of(1315.75), "329.4.4")
|
||||
self.assertEqual(rd.bar_of(0), "1.1.1")
|
||||
|
||||
def test_the_listener_runs(self):
|
||||
def test_the_scripts_run(self):
|
||||
compile(rd.HIT_SCRIPT % json.dumps({"cues": [["/x.wav", 1.5, 0.05]]}), "remote", "exec")
|
||||
compile(rd.MOVE_SCRIPT % json.dumps({"track": "LIGHTING CUES", "moves": [[104, 30, 103.75]]}), "remote", "exec")
|
||||
|
||||
|
||||
class TestTimecode(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/zsh
|
||||
# Adds the status panel's commands to AbletonOSC on this Mac (the Tracks Mac) and reloads AbletonOSC.
|
||||
# Run from NA Ingest: ssh tracks 'zsh -s' < install.sh (statuspanel.py copied next to AbletonOSC first)
|
||||
# Reloading clears AbletonOSC's listeners: reconnect Companion's AbletonOSC connection afterwards.
|
||||
set -e
|
||||
D="$HOME/Music/Ableton/User Library/Remote Scripts/AbletonOSC/abletonosc"
|
||||
cp /tmp/statuspanel.py "$D/statuspanel.py"
|
||||
if ! grep -q "statuspanel" "$D/__init__.py"; then
|
||||
cat >> "$D/__init__.py" <<'PY'
|
||||
|
||||
# The status panel's commands (status-indicators tools/abletonosc): registered with the track handler.
|
||||
try:
|
||||
import importlib as _importlib
|
||||
from . import statuspanel as _statuspanel
|
||||
_importlib.reload(_statuspanel)
|
||||
|
||||
class TrackHandler(TrackHandler):
|
||||
def init_api(self):
|
||||
super().init_api()
|
||||
try:
|
||||
_statuspanel.register(self)
|
||||
except Exception:
|
||||
logger.exception("statuspanel: not registered")
|
||||
except Exception:
|
||||
logger.exception("statuspanel: not loaded")
|
||||
PY
|
||||
fi
|
||||
/usr/bin/python3 -c 'import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.sendto(b"/live/api/reload\0\0\0\0,\0\0\0", ("127.0.0.1", 11000))'
|
||||
echo "installed and reloaded"
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Status panel additions to AbletonOSC (installed by tools/abletonosc/install.sh on the Tracks Mac).
|
||||
|
||||
/live/statuspanel/move_note <track name> <beat> <pitch> <new beat>
|
||||
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).
|
||||
Answers (<track name>, <beat>, <pitch>, <moved: 0 or 1>). Undo in Live (Cmd+Z) puts it back.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("abletonosc")
|
||||
|
||||
|
||||
def register(handler):
|
||||
def move_note(params):
|
||||
song = handler.song
|
||||
name, beat, pitch, new = str(params[0]), float(params[1]), int(params[2]), float(params[3])
|
||||
track = next((t for t in song.tracks if t.name.strip().upper() == name.strip().upper() and t.has_midi_input), None)
|
||||
moved = 0
|
||||
for clip in (track.arrangement_clips if track else []):
|
||||
if not (clip.start_time - 1e-6 <= beat < clip.end_time):
|
||||
continue
|
||||
zero = clip.start_time - (clip.loop_start if clip.looping else clip.start_marker)
|
||||
local, new_local = beat - zero, new - zero
|
||||
notes = clip.get_notes_extended(pitch, 1, local - 0.002, 0.004)
|
||||
for n in notes:
|
||||
end = n.start_time + n.duration
|
||||
if new_local < end:
|
||||
n.start_time, n.duration = new_local, end - new_local
|
||||
moved += 1
|
||||
if moved:
|
||||
clip.apply_note_modifications(notes)
|
||||
break
|
||||
logger.info("statuspanel: %s note %d at %s -> %s: %d moved", name, pitch, beat, new, moved)
|
||||
return name, beat, pitch, moved
|
||||
|
||||
handler.osc_server.add_handler("/live/statuspanel/move_note", move_note)
|
||||
Reference in New Issue
Block a user