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 6289791..c90f981 100644 Binary files a/ready-page.companionconfig and b/ready-page.companionconfig differ diff --git a/schema.py b/schema.py index b59843a..8a175aa 100644 --- a/schema.py +++ b/schema.py @@ -398,6 +398,16 @@ field("ready.ltc_track", "str", "LTC", "The Ableton track that plays the timecod field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "Ready") field("ready.pad_track", "str", "AMBIENT PADS", "The Ableton track whose pad clips are named by key (\"B - WARM - " "CHURCHFRONT PADS\"): the pad under each song must be in the song's key.", "Ready") +field("ready.light_timing_check", "bool", True, "LIGHT TIMING: check every flash and FX cue against the hits in " + "the drum stem under it.", "Ready") +field("ready.light_hit_notes", "list", list(range(30, 40)), "The lighting notes that should land on a hit (30-32 Blinder " + "Flash, 35-39 Blinder FX).", "Ready") +field("ready.hit_stems", "list", ["DRUMS"], "Stems whose hits the cues are timed against (names starting with these: " + "\"Drums (Live)\").", "Ready") +field("ready.light_tolerance_ms", "int", 25, "How far off a cue may be before it is named.", "Ready", lo=10, hi=100) +field("ready.light_library", "path", "", "The folder of lighting cue clips (\"30 Blinder Flash 1.alc\"), for the " + "cues' names.", "Ready", + example="~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI") field("ready.tune_track", "str", "WAVES TUNE", "The Ableton track whose MIDI clips set Waves Tune's key per song " "(clips named like \"Ebmaj\").", "Ready") field("ready.tune_library", "path", "", "The folder of Waves Tune key clips (Ebmaj.alc ...), as the tracks computer sees " diff --git a/settings/ui/app.js b/settings/ui/app.js index c789400..b922b38 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -282,7 +282,7 @@ ${chip(w.running, w.running ? "Watchdog ✓" : "Watchdog down", (w.running ? "Running since " + when(w.since) : "Not running") + (w.version ? " · code " + w.version : "") + (o.heartbeat_age != null ? " · heartbeat " + ago(o.heartbeat_age) : ""))} ${chip(o.companion_ok, o.companion_ok ? "Companion ✓" : "Companion down", o.pages && o.pages.status ? (o.pages.status.ok ? "Pages OK" : "Page " + o.pages.status.page + " is called " + (o.pages.status.live || "?")) : "")} ${chip(o.service_on == null ? null : o.service_on, o.service_on == null ? "Alarms ?" : o.service_on ? "Alarms on" : "Alarms silenced", o.phrases.alarms)} - ${chip(null, "Auto-fix " + (af.paused === true ? "paused" : af.on ? "active" : "waiting"), o.auto_fix.phrase + (af.paused === true ? " (paused with the AUTO-FIX key)" : af.net_wait ? " (waiting out a network blip)" : ""))} + ${chip(null, "Auto-fix " + (af.paused === true ? "paused" : af.test_until ? "testing until " + new Date(af.test_until * 1000).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : af.on ? "active" : "waiting"), o.auto_fix.phrase + (af.paused === true ? " (paused with the AUTO-FIX key)" : af.net_wait ? " (waiting out a network blip)" : ""))} ${Object.keys(o.bypass || {}).length ? chip(null, Object.keys(o.bypass).map((k) => ((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)