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:
2026-10-22 10:31:33 -04:00
co-authored by Claude Opus 5.5
parent d4e4092a9d
commit 625d424bda
8 changed files with 268 additions and 51 deletions
+48 -11
View File
@@ -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):