READY: LIGHT TIMING (flash and FX cues against the drum hits); auto-fix on for testing

- LIGHT TIMING: every blinder flash / FX cue is timed against the Drums (Live) stem under it
  (deactivated clips count) on the Tracks Mac; cues a strong hit is more than 25 ms away from are named
  with song and bar
- Settings Overview: "Auto-fix on for testing" for 15 min to 2 h, outside the hours and over a pause;
  the AUTO-FIX key says TESTING, and it goes back to the hours by itself

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:18:46 -04:00
co-authored by Claude Opus 5.5
parent f179df59f1
commit d4e4092a9d
9 changed files with 300 additions and 19 deletions
+3 -2
View File
@@ -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. |
+17 -1
View File
@@ -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",
+161 -5
View File
@@ -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"]())
Binary file not shown.
+10
View File
@@ -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 "
+6 -3
View File
@@ -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 ? `<a class="chip" href="#history" data-hint="${esc(o.last_change.when + " · " + (o.last_change.by || "") + " · " + (o.last_change.note || ""))}">Last change ${esc(o.last_change.when || "")}</a>` : ""}
@@ -292,7 +292,8 @@
<div class="row" style="margin-top:10px">
<button id="restartwd" ${dis} data-hint="${w.running ? "Restart the watchdog: the deck goes purple for about 5 s; a running fix finishes first" : "Start the watchdog"}">${w.running ? "Restart watchdog" : "Start watchdog"}</button>
${af.split ? `<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms back on now" : "Same as the ALARMS key: no alarms until midnight (auto-fix carries on)"}">${o.service_on === false ? "Alarms on" : "Silence alarms"}</button>
<button id="autofixbtn" ${dis} data-hint="${af.paused ? "Same as the AUTO-FIX key: back to the auto-fix hours now" : "Same as the AUTO-FIX key: nothing is fixed by itself through these (or the next) auto-fix hours; holds still fix"}">${af.paused ? "Resume auto-fix" : "Pause auto-fix"}</button>`
<button id="autofixbtn" ${dis} data-hint="${af.paused ? "Same as the AUTO-FIX key: back to the auto-fix hours now" : af.test_until ? "Stop testing: back to the auto-fix hours now" : "Same as the AUTO-FIX key: nothing is fixed by itself through these (or the next) auto-fix hours; holds still fix"}">${af.paused ? "Resume auto-fix" : af.test_until ? "Stop testing" : "Pause auto-fix"}</button>
${af.test_until ? "" : `<span class="inline" data-hint="Turn auto-fix on now, outside its hours, to try a fix out: it goes back to the hours by itself when the time is up (the AUTO-FIX key says TESTING)"><select id="aftestmins" ${dis}>${[15, 30, 60, 120].map((m) => `<option value="${m}" ${m === 60 ? "selected" : ""}>${m < 60 ? m + " min" : m / 60 + " h"}</option>`).join("")}</select> <button id="aftest" ${dis}>Auto-fix on for testing</button></span>`}`
: `<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms and auto-fix back on now" : "Same as the ALARMS key: no alarms and no auto-fix until midnight"}">${o.service_on === false ? "Alarms + auto-fix on" : "Silence + stop auto-fix"}</button>`}
${w.running ? `<button id="stopwd" ${dis} data-hint="Stop the watchdog: every key purple, no alarms until it is started again">Stop</button>` : ""}
</div></div>
@@ -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.",
+32 -7
View File
@@ -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 <minutes>" 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)
+51 -1
View File
@@ -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)
+20
View File
@@ -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)