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`. 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`). - **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. - **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) ## 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 | | | 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 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 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | |
| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | READY | | 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 | | 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 | | 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 | | 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 | | 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) | | 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. | | 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", "Ext. Out",
"11/12 VOX" "11/12 VOX"
] ]
} },
"light_library": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI"
}, },
"ready_page": { "ready_page": {
"back_pos": [ "back_pos": [
@@ -1400,6 +1401,21 @@
2 2
] ]
}, },
{
"id": "rd_light_time",
"label": "LIGHT TIMING",
"pos": [
1,
5
],
"checks": [
{
"type": "ready",
"item": "light_timing",
"fix": "ready_recheck"
}
]
},
{ {
"id": "rd_keys", "id": "rd_keys",
"label": "SONG 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 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 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 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 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]") 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 prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions
@@ -471,14 +473,41 @@ def summary(path):
pg = c.find("ProgramChange") pg = c.find("ProgramChange")
tune.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")), 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]) 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}, 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": {}} out = {"live_set": live_set(), "bundles": {}}
path = newest_als(folder(P["site"])) path = newest_als(folder(P["site"]))
out["set"] = path out["set"] = path
if path: if path:
st = os.stat(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") cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json")
data = None data = None
try: try:
@@ -498,6 +527,12 @@ if path:
out["mtime"] = st.st_mtime out["mtime"] = st.st_mtime
out["summary"] = data out["summary"] = data
out["finished"] = {} 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 "" lib = os.path.expanduser(P["tune_library"]) if P["tune_library"] else ""
out["tune_library"] = {} out["tune_library"] = {}
for n in sorted(os.listdir(lib)) if lib and os.path.isdir(lib) else []: 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", []), "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"), "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"), "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): 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"]) 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 -------------------------------------------------------------------------- # --- Song keys --------------------------------------------------------------------------
NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} 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), 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), 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), 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( 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)) {"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
src.update(sources or {}) src.update(sources or {})
@@ -1458,7 +1608,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
plan = None plan = None
failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")), 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")), ("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")), ("routing", rc.get("ableton_osc")), ("ears", rc.get("ableton_osc")),
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")), ("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
("lineup", rc.get("lineup_url")), ("mics", True), ("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.update(eval_ableton(rc, plan, al))
lines["keys"] = eval_keys(rc, plan, al) lines["keys"] = eval_keys(rc, plan, al)
lines["tune"] = eval_tune(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"): if rc.get("ltc_note", "LTC"):
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line())) eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
na_songs = song_ltc(rc, plan, al) 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"): if rc.get("locations"):
lines["track_bundle"] = eval_track_bundle(rc, al) lines["track_bundle"] = eval_track_bundle(rc, al)
except Exception as e: # noqa: BLE001 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"): if rc.get("tracks_host") and rc.get("ableton_osc"):
try: try:
lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]()) 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.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 - " 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") "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 " 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") "(clips named like \"Ebmaj\").", "Ready")
field("ready.tune_library", "path", "", "The folder of Waves Tune key clips (Ebmaj.alc ...), as the tracks computer sees " 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(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.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(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.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(", ")) : ""} ${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>` : ""} ${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"> <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> <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> ${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>`} : `<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>` : ""} ${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> </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); } }); 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"); 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); } }; 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("|")); for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
if (!quiet) window.scrollTo(0, 0); 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.", 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.", 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.", 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.", 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.", 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.", 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. # 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") "track_bundle", "prop_bundle")
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix") READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix")
@@ -1132,6 +1132,7 @@ class ServiceMode:
self.override, self.last_sched = saved.get("override"), saved.get("last_sched") self.override, self.last_sched = saved.get("override"), saved.get("last_sched")
self.edge = saved.get("edge") 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.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): def scheduled(self, now):
return in_windows(self.windows, now) return in_windows(self.windows, now)
@@ -1153,6 +1154,12 @@ class ServiceMode:
self._save() self._save()
return sched return sched
return False 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: 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.edge = self.next_change(now) # an override saved before edges were kept
self._save() self._save()
@@ -1168,7 +1175,7 @@ class ServiceMode:
Looked up once a minute.""" Looked up once a minute."""
if self.override is None: if self.override is None:
return None return None
if self.hold: if self.hold or self.test:
return self.edge return self.edge
minute = int(now // 60) minute = int(now // 60)
cached = getattr(self, "_back", None) cached = getattr(self, "_back", None)
@@ -1191,7 +1198,7 @@ class ServiceMode:
self.last_sched = sched self.last_sched = sched
self.override = None if value == sched else value self.override = None if value == sched else value
self.edge = self.next_change(now) if self.override is not None else None self.edge = self.next_change(now) if self.override is not None else None
self.hold = False self.hold = self.test = False
self._save() self._save()
def pause(self, now): def pause(self, now):
@@ -1199,13 +1206,19 @@ class ServiceMode:
(2026-10-21: a press on Saturday night used to do nothing for Sunday).""" (2026-10-21: a press on Saturday night used to do nothing for Sunday)."""
sched = self.scheduled(now) sched = self.scheduled(now)
start = now if sched else self.next_change(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.edge = self.next_change(start) if start else None
self._save() self._save()
def resume(self, now): def resume(self, now):
"""Back to the hours.""" """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() self._save()
@property @property
@@ -1213,7 +1226,8 @@ class ServiceMode:
return self.hold and self.override is False return self.hold and self.override is False
def _save(self): 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): def until(self, now):
"""When the state shown now next changes by itself (an override's end, else the schedule's next edge), """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) when = ("" if same_day else datetime.fromtimestamp(until).strftime("%a ").upper()) + clock12(until)
if engine.autofix.paused: if engine.autofix.paused:
text = "%s\nPAUSED" % tile["label"] + ("\nTIL " + when if when else "") 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: elif on:
text = "%s\nON" % tile["label"] + ("\nTIL " + when if when else "") text = "%s\nON" % tile["label"] + ("\nTIL " + when if when else "")
else: else:
@@ -3751,7 +3767,8 @@ def snapshot_summary(snap, engine, now):
"service_on": engine.service.on(now), "service_on": engine.service.on(now),
"alarms_override": engine.service.override, "alarms_override": engine.service.override,
"auto_fix": {"on": engine.auto_on, "net_wait": engine.auto_paused, "split": engine.split, "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()}, "bypass": {tid: {"since": b.get("since")} for tid, b in engine.bypass.items()},
"failover": dict(engine.failed), "failover": dict(engine.failed),
"burst": engine.burst and {"start": engine.burst["start"], "conns": sorted(engine.burst["conns"]), "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)) target = {"on": True, "off": False}.get(arg, not engine.service.on(now))
engine.service.set(target, now) engine.service.set(target, now)
log.info("SERVICE %s (button)", "ON" if target else "OFF") 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": elif verb == "autofix":
# The AUTO-FIX key pauses and resumes; "off"/"pause" and "on"/"auto"/"resume" say which (NA's ALL AUTO-FIX key). # 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) 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"]) 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): class TestTimecode(unittest.TestCase):
def test_seconds_through_the_tempo_map(self): def test_seconds_through_the_tempo_map(self):
tempo = ableton()["summary"]["tempo"] tempo = ableton()["summary"]["tempo"]
@@ -567,7 +617,7 @@ class TestLook(unittest.TestCase):
cfg["ready"].pop(k) cfg["ready"].pop(k)
cfg["ready"]["prop_url"] = "" cfg["ready"]["prop_url"] = ""
r = rd.look(cfg, now=WEDNESDAY, sources=self.sources()) 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() engine, snap = sp.Engine(cfg, None), sp.Snapshot()
snap.ready = r snap.ready = r
tiles = engine.step(snap, WEDNESDAY) 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("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)) 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): def test_a_hold_from_north_attleboro_flips_a_switch(self):
self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY) self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY)
self.assertTrue(self.engine.autofix.paused) self.assertTrue(self.engine.autofix.paused)