From d4e4092a9d8e111beac917104009323106d9c12b Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 22 Oct 2026 10:18:46 -0400 Subject: [PATCH] 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 --- README.md | 5 +- config.json | 18 +++- readiness.py | 166 +++++++++++++++++++++++++++++++++++-- ready-page.companionconfig | Bin 4752 -> 4905 bytes schema.py | 10 +++ settings/ui/app.js | 9 +- status_panel.py | 39 +++++++-- tests/test_readiness.py | 52 +++++++++++- tests/test_status_panel.py | 20 +++++ 9 files changed, 300 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d8dda48..577b8aa 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl It checks over SSH every 10 s that ProPresenter, Ableton and Church Translate are running, using NA Ingest's key `~/.ssh/id_status_panel`. That key was authorized on the Macs with `remote-access/mac-authorize.sh`. - **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking; press it again to turn them back on; they come back by themselves at midnight. Holds still fix while muted; the Sunday stream-audio failover still switches by itself. The settings page's Overview has the same switch. On a page with no AUTO-FIX key (NA today), ALARMS also stops every auto-fix and the key says both (`ALARMS OFF` / `AUTO-FIX OFF` / `TIL 12AM`). -- **AUTO-FIX** (beside ALARMS; NA bottom-right; `kind: autofix`): its own switch since 2026-10-21. Shows `ON / TIL 9PM` in the auto-fix hours, `OFF / ON SUN 5AM` outside them, `PAUSED / TIL SUN 2PM` when paused. Tap: pause - through the hours it is in now, or through the next ones when tapped outside them (a Saturday-night tap keeps Sunday quiet); tap again: resume. With this key, ALARMS only silences. Holds always fix. From NA's LOCATION page a hold is the tap. The settings page has Pause / Resume auto-fix. +- **AUTO-FIX** (beside ALARMS; NA bottom-right; `kind: autofix`): its own switch since 2026-10-21. Shows `ON / TIL 9PM` in the auto-fix hours, `OFF / ON SUN 5AM` outside them, `PAUSED / TIL SUN 2PM` when paused. Tap: pause - through the hours it is in now, or through the next ones when tapped outside them (a Saturday-night tap keeps Sunday quiet); tap again: resume. With this key, ALARMS only silences. Holds always fix. From NA's LOCATION page a hold is the tap. The settings page has Pause / Resume auto-fix, and **Auto-fix on for testing** (15 min to 2 h): auto-fix runs now, outside its hours and even when paused, the key says `TESTING / TIL 10:45AM`, and it goes back to the hours by itself when the time is up (**Stop testing** ends it early). The day's auto-fix log says when testing started. - **LIGHTS** (Southcoast; `kind: bypass`, `"bypass": {"peers": ["Lighting"], "hosts": ["", "sc_tracks_new"]}`): hold 2 s to hand the lights to a lighting operator. Every network MIDI link to Lighting (ProPresenter's session, Tracks' session, and Companion's 56 Midi-Lighting buttons, which go out through ProPresenter's session) is taken out, the MIDI checks stop calling it lost and auto-fix stops putting it back; the key says `LIGHTS / OPERATOR / MIDI OFF` (steady blue). A link that comes back while the operator has it is taken out again within 10 s. Hold again: everything taken out is put back as it was, and every watched link to Lighting is reconnected (`LIGHTS / AUTO`). A peer that had called in (no address, ":5004") is not called back: it rejoins when Lighting calls again. Survives a restart; a hold from NA's LOCATION page works too. `"conns"` can also switch Companion connections off for the operator. ## Auto-fix on Sundays (06:30–13:30) @@ -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 | | | | +| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | LIGHT TIMING | | | | row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | | | row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | READY | @@ -151,6 +151,7 @@ everything on the page at once. | TRACKS | this weekend's set exists (`Ableton/Projects/NA/261018 NA Project`), it has a song group for every PCO song, and Live last opened it ("LIVE HAS OLD SET") | looks again | | FOH MIX | **NOT MIXED** (amber): every track of a song is still at 0 dB. **TO MASTER**: a stem goes to Master instead of out to the console | looks again | | LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again | +| 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 around it (less than half a 16th note either way) for the strongest hit. **3 CUES OFF** (amber): a strong hit more than 25 ms before or after the cue while the cue itself sits on a quiet spot; the details say `JEHOVAH bar 329.2.1: Blinder Flash 2 is 37 ms before the hit` (bars as Live shows them, 4/4). Listened to once per saved set. | looks again (move the note in Live by hand: Live's control script cannot edit notes in the arrangement) | | SONG KEYS | every song is in the key PCO gives it: the set's locator for it (`3- JEHOVAH - B`), the pad clip under it on `AMBIENT PADS` (`B - WARM - CHURCHFRONT PADS`) and a `Finished Songs` / `Finished Custom Songs` folder (`JEHOVAH [B]`). Eb and D# are the same key. **JEHOVAH B NOT Db** (red): the locator is in another key. **JEHOVAH PAD D NOT B** (red): the wrong pad. **JEHOVAH ONLY Db** (amber): Finished Songs has the song, but not in this key (save the new version there). A song that is not in Finished Songs at all is only noted. | looks again | | W TUNE | the `WAVES TUNE` clip that starts each song (a clip from `Ableton/Waves Tune - midi Program Change`, sent to Waves Tune as a program change) is the song's PCO key; a minor song is fine on its relative major. **JEHOVAH TUNE Db** names the song; the details say which clip to put at which bar. **TUNE PGM**: a renamed clip that sends another key's program. | looks again (Live's control script cannot change a clip in the arrangement: swap the clip by hand) | | ROUTING | read **live** from Ableton (AbletonOSC, so tracks just dragged in count before the set is saved): every stem goes where `ready.routing` says (Piano, Keys, Organ → `Ext. Out 5/6 Keys`; AG, EG, Bass → `7/8 GTRS`; Drums, Perc, FX, Synth Bass → `9/10 PERC / FX`; BGVS, Choir, Vox FX → `11/12 VOX`). The table was taken from 261025 NA Project and is edited on the settings page; "EG 13" goes where every other EG goes. **PIANO TO MASTER** (red) names the stem; **NEW STEM BANJO** (amber): a stem name the table does not know. | puts every stem back where the table says (and IN EARS' sends to 0 dB). Runs by itself in the auto-fix hours. | diff --git a/config.json b/config.json index a48c5be..e8a8846 100644 --- a/config.json +++ b/config.json @@ -1179,7 +1179,8 @@ "Ext. Out", "11/12 VOX" ] - } + }, + "light_library": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI" }, "ready_page": { "back_pos": [ @@ -1400,6 +1401,21 @@ 2 ] }, + { + "id": "rd_light_time", + "label": "LIGHT TIMING", + "pos": [ + 1, + 5 + ], + "checks": [ + { + "type": "ready", + "item": "light_timing", + "fix": "ready_recheck" + } + ] + }, { "id": "rd_keys", "label": "SONG KEYS", diff --git a/readiness.py b/readiness.py index 8fd9a24..454d013 100644 --- a/readiness.py +++ b/readiness.py @@ -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"]()) diff --git a/ready-page.companionconfig b/ready-page.companionconfig index 628979159f3843e52b80d851d109b3d0264c9043..c90f981d5ade24c2454871a8f7b494829afa474c 100644 GIT binary patch delta 4860 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 ((cfg().tiles.find((t) => t.id === k) || {}).label || k)).join(", ") + ": operator", "Handed to an operator with its key: its MIDI is out until the key is held again") : ""} ${Object.keys(o.running || {}).length ? chip(null, "Fix running", Object.values(o.running).map((r) => r.what + " on " + r.key).join(", ")) : ""} ${o.last_change ? `Last change ${esc(o.last_change.when || "")}` : ""} @@ -292,7 +292,8 @@
${af.split ? ` - ` + + ${af.test_until ? "" : ` `}` : ``} ${w.running ? `` : ""}
@@ -301,7 +302,8 @@ main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } }); const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP"); main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; - const afb = main.querySelector("#autofixbtn"); if (afb) afb.onclick = async () => { try { await post("/api/command", { text: af.paused ? "autofix resume" : "autofix pause" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; + const afb = main.querySelector("#autofixbtn"); if (afb) afb.onclick = async () => { try { await post("/api/command", { text: af.paused || af.test_until ? "autofix resume" : "autofix pause" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; + const aft = main.querySelector("#aftest"); if (aft) aft.onclick = async () => { try { await post("/api/command", { text: "autofix test " + main.querySelector("#aftestmins").value }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|")); if (!quiet) window.scrollTo(0, 0); } @@ -581,6 +583,7 @@ tracks: "This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.", mix_foh: "Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.", lights: "The lighting cue track has a clip under every song.", + light_timing: "Every blinder flash and FX cue on the lighting track lands on the hit in the drum stem under it. A cue whose hit is a few ms before or after it (while the cue itself sits on a quiet spot) is named with its song and bar, to be moved by hand.", tune: "The Waves Tune clip that starts each song is the song's Planning Center key, and sends that key's program. Swap the clip by hand (the hold says which and where); a minor song is fine on its relative major.", routing: "Read live from Ableton (AbletonOSC): every stem goes where the routing template says (Keys → Ext. Out 5/6 Keys). Hold to put them back.", ears: "Read live from Ableton: no song stem has its in-ear send (send A) at -inf, as dragged-in songs do. Hold to set them to 0 dB.", diff --git a/status_panel.py b/status_panel.py index 25055b0..ab841e2 100644 --- a/status_panel.py +++ b/status_panel.py @@ -817,7 +817,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", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics", +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") @@ -1132,6 +1132,7 @@ class ServiceMode: self.override, self.last_sched = saved.get("override"), saved.get("last_sched") self.edge = saved.get("edge") self.hold = bool(saved.get("hold")) # a pause that lasts through the hours, not just to the next edge + self.test = bool(saved.get("test")) # AUTO-FIX turned on for testing: on until its edge, then the hours again def scheduled(self, now): return in_windows(self.windows, now) @@ -1153,6 +1154,12 @@ class ServiceMode: self._save() return sched return False + if self.test: + if self.edge is None or now >= self.edge: + self.override, self.test, self.edge, self.last_sched = None, False, None, sched + self._save() + return sched + return True if self.override is not None and self.edge is None: self.edge = self.next_change(now) # an override saved before edges were kept self._save() @@ -1168,7 +1175,7 @@ class ServiceMode: Looked up once a minute.""" if self.override is None: return None - if self.hold: + if self.hold or self.test: return self.edge minute = int(now // 60) cached = getattr(self, "_back", None) @@ -1191,7 +1198,7 @@ class ServiceMode: self.last_sched = sched self.override = None if value == sched else value self.edge = self.next_change(now) if self.override is not None else None - self.hold = False + self.hold = self.test = False self._save() def pause(self, now): @@ -1199,13 +1206,19 @@ class ServiceMode: (2026-10-21: a press on Saturday night used to do nothing for Sunday).""" sched = self.scheduled(now) start = now if sched else self.next_change(now) - self.override, self.hold, self.last_sched = False, True, sched + self.override, self.hold, self.test, self.last_sched = False, True, False, sched self.edge = self.next_change(start) if start else None self._save() def resume(self, now): """Back to the hours.""" - self.override, self.hold, self.edge, self.last_sched = None, False, None, self.scheduled(now) + self.override, self.hold, self.test, self.edge, self.last_sched = None, False, False, None, self.scheduled(now) + self._save() + + def test_on(self, now, minutes): + """On for testing, whatever the hours, for `minutes` (5 to 240); then back to the hours by itself.""" + self.override, self.hold, self.test, self.last_sched = True, False, True, self.scheduled(now) + self.edge = now + max(5, min(240, int(minutes))) * 60 self._save() @property @@ -1213,7 +1226,8 @@ class ServiceMode: return self.hold and self.override is False def _save(self): - self.state.set(self.name, {"override": self.override, "last_sched": self.last_sched, "edge": self.edge, "hold": self.hold}) + self.state.set(self.name, {"override": self.override, "last_sched": self.last_sched, "edge": self.edge, "hold": self.hold, + "test": self.test}) def until(self, now): """When the state shown now next changes by itself (an override's end, else the schedule's next edge), @@ -2379,6 +2393,8 @@ def render(engine, service_on): when = ("" if same_day else datetime.fromtimestamp(until).strftime("%a ").upper()) + clock12(until) if engine.autofix.paused: text = "%s\nPAUSED" % tile["label"] + ("\nTIL " + when if when else "") + elif engine.autofix.test and on: + text = "%s\nTESTING" % tile["label"] + ("\nTIL " + when if when else "") elif on: text = "%s\nON" % tile["label"] + ("\nTIL " + when if when else "") else: @@ -3751,7 +3767,8 @@ def snapshot_summary(snap, engine, now): "service_on": engine.service.on(now), "alarms_override": engine.service.override, "auto_fix": {"on": engine.auto_on, "net_wait": engine.auto_paused, "split": engine.split, - "switch": engine.autofix.on(now), "override": engine.autofix.override, "paused": engine.autofix.paused}, + "switch": engine.autofix.on(now), "override": engine.autofix.override, "paused": engine.autofix.paused, + "test_until": engine.autofix.edge if engine.autofix.test else None}, "bypass": {tid: {"since": b.get("since")} for tid, b in engine.bypass.items()}, "failover": dict(engine.failed), "burst": engine.burst and {"start": engine.burst["start"], "conns": sorted(engine.burst["conns"]), @@ -3797,6 +3814,14 @@ def handle_commands(text, engine, comp, snap, now): target = {"on": True, "off": False}.get(arg, not engine.service.on(now)) engine.service.set(target, now) log.info("SERVICE %s (button)", "ON" if target else "OFF") + elif verb == "autofix" and arg == "test": + # The settings page: "autofix test " turns auto-fix on now, outside its hours, to try it out. + mins = int(words[2]) if len(words) > 2 and words[2].isdigit() else 60 + engine.autofix.test_on(now, mins) + log.info("AUTO-FIX ON FOR TESTING until %s", datetime.fromtimestamp(engine.autofix.edge).strftime("%H:%M")) + if engine.autolog: + engine.autolog.write(now, "PANEL", "auto-fix on for testing (settings page) until %s" + % datetime.fromtimestamp(engine.autofix.edge).strftime("%H:%M")) elif verb == "autofix": # The AUTO-FIX key pauses and resumes; "off"/"pause" and "on"/"auto"/"resume" say which (NA's ALL AUTO-FIX key). pause = {"off": True, "pause": True, "on": False, "auto": False, "resume": False}.get(arg, not engine.autofix.paused) diff --git a/tests/test_readiness.py b/tests/test_readiness.py index 3271853..a9066df 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -434,6 +434,56 @@ class TestWavesTune(unittest.TestCase): self.assertEqual(msgs(rd.eval_tune(RC, keyed(GOOD), ableton())), ["NO TUNE CLIPS"]) +def with_cues(al=None): + """Flashes over Can't Help But Praise's drums (beats 100-190 of the fixture, at 151 BPM there).""" + al = al or ableton() + al["summary"]["tracks"].append(track("25", "42-Drums (Live)", group="20", clips=((100, 190, None),))) + al["summary"]["cues"] = [[104, 30], [104, 36], [108, 31], [112, 35], [116, 30], [120, 99]] + al["summary"]["hit_src"] = [["25", 100, 190, "/Projects/NA/261018 NA Project/MultiTracks/Drums (Live).wav", False, 0.0]] + al["light_names"] = {"30": "Blinder Flash 1", "31": "Blinder Flash 2", "35": "Blinder FX 1", "36": "Blinder FX 2"} + return al + + +class TestLightTiming(unittest.TestCase): + 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 + + 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 + [-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 + 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.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]] + 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]) + + 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_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): + compile(rd.HIT_SCRIPT % json.dumps({"cues": [["/x.wav", 1.5, 0.05]]}), "remote", "exec") + + class TestTimecode(unittest.TestCase): def test_seconds_through_the_tempo_map(self): tempo = ableton()["summary"]["tempo"] @@ -567,7 +617,7 @@ class TestLook(unittest.TestCase): cfg["ready"].pop(k) cfg["ready"]["prop_url"] = "" r = rd.look(cfg, now=WEDNESDAY, sources=self.sources()) - self.assertEqual(sorted(r["items"]), ["ears", "keys", "lights", "mics", "mix_foh", "routing", "timecode", "tracks", "tune"]) # LTC: tracks vs PCO + self.assertEqual(sorted(r["items"]), ["ears", "keys", "light_timing", "lights", "mics", "mix_foh", "routing", "timecode", "tracks", "tune"]) # LTC: tracks vs PCO engine, snap = sp.Engine(cfg, None), sp.Snapshot() snap.ready = r tiles = engine.step(snap, WEDNESDAY) diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index 65dfd43..409478d 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -2521,6 +2521,26 @@ class TestSwitches(unittest.TestCase): self.assertTrue(any("Auto-fix is the AUTO-FIX key" in n for n in notes)) self.assertTrue(any("pause auto-fix" in n for n in notes)) + def test_auto_fix_on_for_testing(self): + ran = [] + self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True + self.snap.remote["tracks\0pgrep -xq Live"] = False # Ableton closed + thursday = SUNDAY - 3 * 86400 # outside the hours + run_steps(self.engine, self.snap, thursday, 25) + self.assertEqual(self.engine.auto_fix(self.snap, mock_comp(), thursday + 30), []) + sp.handle_commands("autofix test 30", self.engine, mock_comp(), self.snap, thursday + 31) + tiles = self.engine.step(self.snap, thursday + 32) + self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nTESTING\nTIL 8:30AM") + self.engine.auto_fix(self.snap, mock_comp(), thursday + 40) + time.sleep(0.2) + self.assertEqual(ran, ["ableton"]) # fixed by itself on a Thursday + self.assertTrue(self.new_engine().autofix.test) # survives a restart + self.assertFalse(self.engine.autofix.on(thursday + 31 + 30 * 60)) # half an hour later: back to the hours + self.assertEqual(self.engine.step(self.snap, thursday + 31 + 30 * 60 + 1)["autofix"]["x"], "AUTO-FIX\nOFF\nON SUN 6:30AM") + sp.handle_commands("autofix test 30", self.engine, mock_comp(), self.snap, thursday + 4000) + sp.handle_commands("autofix resume", self.engine, mock_comp(), self.snap, thursday + 4060) # Stop testing + self.assertFalse(self.engine.autofix.on(thursday + 4061)) + def test_a_hold_from_north_attleboro_flips_a_switch(self): self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY) self.assertTrue(self.engine.autofix.paused)