diff --git a/README.md b/README.md index d8dda48..577b8aa 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl It checks over SSH every 10 s that ProPresenter, Ableton and Church Translate are running, using NA Ingest's key `~/.ssh/id_status_panel`. That key was authorized on the Macs with `remote-access/mac-authorize.sh`. - **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking; press it again to turn them back on; they come back by themselves at midnight. Holds still fix while muted; the Sunday stream-audio failover still switches by itself. The settings page's Overview has the same switch. On a page with no AUTO-FIX key (NA today), ALARMS also stops every auto-fix and the key says both (`ALARMS OFF` / `AUTO-FIX OFF` / `TIL 12AM`). -- **AUTO-FIX** (beside ALARMS; NA bottom-right; `kind: autofix`): its own switch since 2026-10-21. Shows `ON / TIL 9PM` in the auto-fix hours, `OFF / ON SUN 5AM` outside them, `PAUSED / TIL SUN 2PM` when paused. Tap: pause - through the hours it is in now, or through the next ones when tapped outside them (a Saturday-night tap keeps Sunday quiet); tap again: resume. With this key, ALARMS only silences. Holds always fix. From NA's LOCATION page a hold is the tap. The settings page has Pause / Resume auto-fix. +- **AUTO-FIX** (beside ALARMS; NA bottom-right; `kind: autofix`): its own switch since 2026-10-21. Shows `ON / TIL 9PM` in the auto-fix hours, `OFF / ON SUN 5AM` outside them, `PAUSED / TIL SUN 2PM` when paused. Tap: pause - through the hours it is in now, or through the next ones when tapped outside them (a Saturday-night tap keeps Sunday quiet); tap again: resume. With this key, ALARMS only silences. Holds always fix. From NA's LOCATION page a hold is the tap. The settings page has Pause / Resume auto-fix, and **Auto-fix on for testing** (15 min to 2 h): auto-fix runs now, outside its hours and even when paused, the key says `TESTING / TIL 10:45AM`, and it goes back to the hours by itself when the time is up (**Stop testing** ends it early). The day's auto-fix log says when testing started. - **LIGHTS** (Southcoast; `kind: bypass`, `"bypass": {"peers": ["Lighting"], "hosts": ["", "sc_tracks_new"]}`): hold 2 s to hand the lights to a lighting operator. Every network MIDI link to Lighting (ProPresenter's session, Tracks' session, and Companion's 56 Midi-Lighting buttons, which go out through ProPresenter's session) is taken out, the MIDI checks stop calling it lost and auto-fix stops putting it back; the key says `LIGHTS / OPERATOR / MIDI OFF` (steady blue). A link that comes back while the operator has it is taken out again within 10 s. Hold again: everything taken out is put back as it was, and every watched link to Lighting is reconnected (`LIGHTS / AUTO`). A peer that had called in (no address, ":5004") is not called back: it rejoins when Lighting calls again. Survives a restart; a hold from NA's LOCATION page works too. `"conns"` can also switch Companion connections off for the operator. ## Auto-fix on Sundays (06:30–13:30) @@ -129,7 +129,7 @@ HyperDeck is recording). **STATUS PAGE** (bottom left) goes back. | | col 1 | col 2 | col 3 | col 4 | col 5 | col 6 | col 7 | |--------|-------|-------|-------|-------|-------|-------|-------| | row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE | -| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | | | | +| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | LIGHT TIMING | | | | row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | | | row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | READY | @@ -151,6 +151,7 @@ everything on the page at once. | TRACKS | this weekend's set exists (`Ableton/Projects/NA/261018 NA Project`), it has a song group for every PCO song, and Live last opened it ("LIVE HAS OLD SET") | looks again | | FOH MIX | **NOT MIXED** (amber): every track of a song is still at 0 dB. **TO MASTER**: a stem goes to Master instead of out to the console | looks again | | LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again | +| LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens around it (less than half a 16th note either way) for the strongest hit. **3 CUES OFF** (amber): a strong hit more than 25 ms before or after the cue while the cue itself sits on a quiet spot; the details say `JEHOVAH bar 329.2.1: Blinder Flash 2 is 37 ms before the hit` (bars as Live shows them, 4/4). Listened to once per saved set. | looks again (move the note in Live by hand: Live's control script cannot edit notes in the arrangement) | | SONG KEYS | every song is in the key PCO gives it: the set's locator for it (`3- JEHOVAH - B`), the pad clip under it on `AMBIENT PADS` (`B - WARM - CHURCHFRONT PADS`) and a `Finished Songs` / `Finished Custom Songs` folder (`JEHOVAH [B]`). Eb and D# are the same key. **JEHOVAH B NOT Db** (red): the locator is in another key. **JEHOVAH PAD D NOT B** (red): the wrong pad. **JEHOVAH ONLY Db** (amber): Finished Songs has the song, but not in this key (save the new version there). A song that is not in Finished Songs at all is only noted. | looks again | | W TUNE | the `WAVES TUNE` clip that starts each song (a clip from `Ableton/Waves Tune - midi Program Change`, sent to Waves Tune as a program change) is the song's PCO key; a minor song is fine on its relative major. **JEHOVAH TUNE Db** names the song; the details say which clip to put at which bar. **TUNE PGM**: a renamed clip that sends another key's program. | looks again (Live's control script cannot change a clip in the arrangement: swap the clip by hand) | | ROUTING | read **live** from Ableton (AbletonOSC, so tracks just dragged in count before the set is saved): every stem goes where `ready.routing` says (Piano, Keys, Organ → `Ext. Out 5/6 Keys`; AG, EG, Bass → `7/8 GTRS`; Drums, Perc, FX, Synth Bass → `9/10 PERC / FX`; BGVS, Choir, Vox FX → `11/12 VOX`). The table was taken from 261025 NA Project and is edited on the settings page; "EG 13" goes where every other EG goes. **PIANO TO MASTER** (red) names the stem; **NEW STEM BANJO** (amber): a stem name the table does not know. | puts every stem back where the table says (and IN EARS' sends to 0 dB). Runs by itself in the auto-fix hours. | diff --git a/config.json b/config.json index a48c5be..e8a8846 100644 --- a/config.json +++ b/config.json @@ -1179,7 +1179,8 @@ "Ext. Out", "11/12 VOX" ] - } + }, + "light_library": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI" }, "ready_page": { "back_pos": [ @@ -1400,6 +1401,21 @@ 2 ] }, + { + "id": "rd_light_time", + "label": "LIGHT TIMING", + "pos": [ + 1, + 5 + ], + "checks": [ + { + "type": "ready", + "item": "light_timing", + "fix": "ready_recheck" + } + ] + }, { "id": "rd_keys", "label": "SONG KEYS", diff --git a/readiness.py b/readiness.py index 8fd9a24..454d013 100644 --- a/readiness.py +++ b/readiness.py @@ -14,6 +14,8 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p routing every stem goes where this location's template says (Keys -> Ext. Out 5/6 Keys), read live from Ableton over AbletonOSC, so tracks just dragged in count before the set is saved ears no song stem's in-ear send (send A) is at -inf: dragged-in songs come in that way + light_timing every blinder flash and FX cue on the lighting track lands on the hit in the drum stem under it + (a hit a few ms before or after the cue, with the cue itself on a quiet spot, is named) keys every song is in the key PCO gives it: the set's locator for it ("3- JEHOVAH - B"), the pad clip under it ("B - WARM - CHURCHFRONT PADS") and a Finished Songs folder ("JEHOVAH [B]") prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions @@ -471,14 +473,41 @@ def summary(path): pg = c.find("ProgramChange") tune.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")), c.find("Name").get("Value"), int(pg.get("Value")) if pg is not None else None]) + cues, hit_src = [], [] + for t in list(ls.find("Tracks")): + name = t.find("Name/EffectiveName").get("Value").strip() + if name.upper() == P["lighting_track"].strip().upper(): + for c in t.findall(".//ArrangerAutomation/Events/MidiClip"): + if c.find("Disabled") is not None and c.find("Disabled").get("Value") == "true": + continue + start, end = float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")) + offset = float(c.find("Loop/LoopStart").get("Value")) + for kt in c.iter("KeyTrack"): + k = int(kt.find("MidiKey").get("Value")) + if k not in P["hit_notes"]: + continue + for n in kt.iter("MidiNoteEvent"): + b = start + float(n.get("Time")) - offset + if n.get("IsEnabled") != "false" and start <= b < end: + cues.append([b, k]) + stem = re.sub(r"^\s*\d+\s*-\s*", "", re.sub(r"\[.*?\]", " ", name.upper())).strip() + if t.tag == "AudioTrack" and any(stem.startswith(h.upper()) for h in P["hit_stems"]): + for c in t.findall(".//ArrangerAutomation/Events/AudioClip"): + # a deactivated clip counts: the drummer plays live, and the stem still says where the hits are + fr = c.find("SampleRef/FileRef") + rel = fr.find("RelativePath").get("Value") if fr.find("RelativePath") is not None else "" + absp = fr.find("Path").get("Value") if fr.find("Path") is not None else "" + f = os.path.join(os.path.dirname(path), rel) if rel and os.path.exists(os.path.join(os.path.dirname(path), rel)) else absp + hit_src.append([t.get("Id"), float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")), + f, c.find("IsWarped").get("Value") == "true", float(c.find("Loop/LoopStart").get("Value"))]) return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events}, - "ltc": ltc, "pads": pads, "tune": tune} + "ltc": ltc, "pads": pads, "tune": tune, "cues": cues, "hit_src": hit_src} out = {"live_set": live_set(), "bundles": {}} path = newest_als(folder(P["site"])) out["set"] = path if path: st = os.stat(path) - key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], 4] # 4: the summary's layout + key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], P["lighting_track"], P["hit_notes"], P["hit_stems"], 6] cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json") data = None try: @@ -498,6 +527,12 @@ if path: out["mtime"] = st.st_mtime out["summary"] = data out["finished"] = {} +out["light_names"] = {} +llib = os.path.expanduser(P["light_library"]) if P["light_library"] else "" +for n in sorted(os.listdir(llib)) if llib and os.path.isdir(llib) else []: + m = re.match(r"^(\d+)\s+(.+)\.alc$", n) + if m: + out["light_names"][m.group(1)] = m.group(2) lib = os.path.expanduser(P["tune_library"]) if P["tune_library"] else "" out["tune_library"] = {} for n in sorted(os.listdir(lib)) if lib and os.path.isdir(lib) else []: @@ -522,7 +557,9 @@ def ableton_params(rc, plan): "date": datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"), "locations": rc.get("locations", []), "ltc_track": rc.get("ltc_track", "LTC"), "pad_track": rc.get("pad_track", "AMBIENT PADS"), "finished": rc.get("finished_folders", []), "tune_track": rc.get("tune_track", "WAVES TUNE"), - "tune_library": rc.get("tune_library", "")} + "tune_library": rc.get("tune_library", ""), "lighting_track": rc.get("lighting_track", "LIGHTING CUES"), + "hit_notes": rc.get("light_hit_notes", list(range(30, 40))), "hit_stems": rc.get("hit_stems", ["DRUMS"]), + "light_library": rc.get("light_library", "")} def ableton_look(cfg, rc, plan): @@ -807,6 +844,118 @@ def mix_plan(rc, live): return routing, ears, sorted(changes.values(), key=lambda change: change["index"]) +# --- Lighting cue timing ------------------------------------------------------------------- + +HIT_SCRIPT = r""" +import aifc, audioop, json, wave +P = json.loads(%r) +FRAME = 0.005 +def look(path, sec, span): + aiff = path.lower().endswith((".aif", ".aiff")) + f = aifc.open(path, "rb") if aiff else wave.open(path, "rb") + try: + sr, w, ch = f.getframerate(), f.getsampwidth(), f.getnchannels() + a = max(0, int((sec - span - 0.03) * sr)) + f.setpos(a) + hop, n = int(FRAME * sr), int((2 * span + 0.03) / FRAME) + data = f.readframes(hop * (n + 1)) + finally: + f.close() + if aiff: + data = audioop.byteswap(data, w) # AIFF is big-endian + size = hop * w * ch + rms = [audioop.rms(data[i * size:(i + 1) * size], w) for i in range(n)] + rise = [max(0, rms[i] - sum(rms[max(0, i - 4):i]) / max(1, min(4, i))) for i in range(n)] + times = [a / sr + i * FRAME for i in range(n)] + near = [i for i in range(n) if abs(times[i] - sec) <= span] + at = max([rise[i] for i in near if abs(times[i] - sec) <= 0.012] or [0]) + best = max(near, key=lambda i: rise[i]) if near else None + return [round(times[best] - sec, 4), rise[best], at] if best is not None else None +out = [] +for path, sec, span in P["cues"]: + try: + out.append(look(path, sec, span)) + except Exception as e: + out.append(str(e)[:60]) +print(json.dumps(out)) +""" +HITS = {} # (set, its mtime) -> the hit list, so an unchanged set is not listened to again + + +def cue_times(rc, summary): + """[(beat, note, song group, file, second in the file, search span)] for every flash and FX cue over a drum stem.""" + groups = song_groups(summary) + owner = {t["id"]: g for g, members in groups.items() for t in members} + tempo = summary["tempo"] + out, seen, wanted = [], set(), set(rc.get("light_hit_notes", range(30, 40))) + for beat, note in sorted(summary.get("cues") or []): + if beat in seen or note not in wanted: + continue # a flash and an FX on the same 16th: one hit + src = next((h for h in summary.get("hit_src") or [] if h[1] <= beat < h[2] and not h[4]), None) + if not src: + continue + seen.add(beat) + sec = seconds_at(tempo, beat) - seconds_at(tempo, src[1]) + src[5] + sixteenth = seconds_at(tempo, beat + 0.25) - seconds_at(tempo, beat) + out.append((beat, note, owner.get(src[0], ""), src[3], sec, min(0.08, 0.45 * sixteenth))) + return out + + +def bar_of(beat): + """Live's position: beat 1315.75 -> "329.4.4" (4/4).""" + return "%d.%d.%d" % (beat // 4 + 1, beat % 4 + 1, (beat % 1) * 4 + 1) + + +def eval_light_timing(rc, look, hits): + """Cues whose hit is clearly a few ms off: the strongest hit near the cue is at least `light_tolerance_ms` + away, is a real hit for that song, and is clearly stronger than what is under the cue itself.""" + line = Line() + if not look.get("set"): + line.fault(ERR, "NO SET", "no set to check") + return line + cues = cue_times(rc, look["summary"]) + if not cues: + line.info = "NO CUES ON DRUMS" + return line + names = look.get("light_names") or {} + tol = rc.get("light_tolerance_ms", 25) / 1000.0 + by_file = {} + for c, h in zip(cues, hits): + if isinstance(h, list): + by_file.setdefault(c[3], []).append(h[1]) + typical = {f: sorted(v)[len(v) // 2] for f, v in by_file.items()} + off, broken = [], set() + for c, h in zip(cues, hits): + if not isinstance(h, list): + broken.add(os.path.basename(c[3])) + continue + dt, best, at = h + if abs(dt) > tol and best >= 0.3 * typical.get(c[3], 0) and best > 0 and (at == 0 or best / at >= 1.5): + off.append((c, dt)) + said = ["%s bar %s: %s is %d ms %s the hit" % (song or "?", bar_of(beat), names.get(str(note), "note %d" % note), + abs(dt) * 1000, "before" if dt > 0 else "after") + for (beat, note, song, _, _, _), dt in off] + if off: + line.fault(WARN, "%d CUES OFF" % len(off) if len(off) > 1 else "CUE OFF %s" % bar_of(off[0][0][0]), said[0]) + line.notes += said[1:] + for f in sorted(broken): + line.notes.append("could not read %s" % f) + line.info = "%d CUES ON TIME" % (len(cues) - len(off) - sum(1 for h in hits if not isinstance(h, list))) + return line + + +def light_hits(cfg, rc, look): + """The hits under every cue, listened to on the Tracks Mac (once per saved set).""" + key = (look.get("set"), look.get("mtime")) + if key not in HITS: + cues = cue_times(rc, look["summary"]) + hits = run_remote(cfg, rc["tracks_host"], HIT_SCRIPT % json.dumps({"cues": [[c[3], c[4], c[5]] for c in cues]}), + timeout=rc.get("remote_timeout", 90)) if cues else [] + HITS.clear() + HITS[key] = hits + return HITS[key] + + # --- Song keys -------------------------------------------------------------------------- NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} @@ -1440,6 +1589,7 @@ def look(cfg, now=None, data_dir=None, sources=None): resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6), macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60), companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc), + hits=lambda al: light_hits(cfg, rc, al), prop_bundle=lambda plan: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps( {"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30)) src.update(sources or {}) @@ -1458,7 +1608,8 @@ def look(cfg, now=None, data_dir=None, sources=None): plan = None failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")), ("lights", rc.get("tracks_host")), ("keys", rc.get("tracks_host")), - ("tune", rc.get("tracks_host")), ("prop", rc.get("prop_url")), + ("tune", rc.get("tracks_host")), ("light_timing", rc.get("tracks_host")), + ("prop", rc.get("prop_url")), ("routing", rc.get("ableton_osc")), ("ears", rc.get("ableton_osc")), ("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")), ("lineup", rc.get("lineup_url")), ("mics", True), @@ -1493,6 +1644,11 @@ def look(cfg, now=None, data_dir=None, sources=None): lines.update(eval_ableton(rc, plan, al)) lines["keys"] = eval_keys(rc, plan, al) lines["tune"] = eval_tune(rc, plan, al) + if rc.get("light_timing_check", True): + try: + lines["light_timing"] = eval_light_timing(rc, al, src["hits"](al) if al.get("set") else []) + except Exception as e: # noqa: BLE001 - a stem it cannot read must not hide the rest + failed(["light_timing"], e) if rc.get("ltc_note", "LTC"): eval_ltc(rc, plan, al, lines.setdefault("timecode", Line())) na_songs = song_ltc(rc, plan, al) @@ -1515,7 +1671,7 @@ def look(cfg, now=None, data_dir=None, sources=None): if rc.get("locations"): lines["track_bundle"] = eval_track_bundle(rc, al) except Exception as e: # noqa: BLE001 - failed(["tracks", "mix_foh", "lights", "keys", "tune"] + (["track_bundle"] if rc.get("locations") else []), e) + failed(["tracks", "mix_foh", "lights", "keys", "tune", "light_timing"] + (["track_bundle"] if rc.get("locations") else []), e) if rc.get("tracks_host") and rc.get("ableton_osc"): try: lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]()) diff --git a/ready-page.companionconfig b/ready-page.companionconfig index 6289791..c90f981 100644 Binary files a/ready-page.companionconfig and b/ready-page.companionconfig differ diff --git a/schema.py b/schema.py index b59843a..8a175aa 100644 --- a/schema.py +++ b/schema.py @@ -398,6 +398,16 @@ field("ready.ltc_track", "str", "LTC", "The Ableton track that plays the timecod field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "Ready") field("ready.pad_track", "str", "AMBIENT PADS", "The Ableton track whose pad clips are named by key (\"B - WARM - " "CHURCHFRONT PADS\"): the pad under each song must be in the song's key.", "Ready") +field("ready.light_timing_check", "bool", True, "LIGHT TIMING: check every flash and FX cue against the hits in " + "the drum stem under it.", "Ready") +field("ready.light_hit_notes", "list", list(range(30, 40)), "The lighting notes that should land on a hit (30-32 Blinder " + "Flash, 35-39 Blinder FX).", "Ready") +field("ready.hit_stems", "list", ["DRUMS"], "Stems whose hits the cues are timed against (names starting with these: " + "\"Drums (Live)\").", "Ready") +field("ready.light_tolerance_ms", "int", 25, "How far off a cue may be before it is named.", "Ready", lo=10, hi=100) +field("ready.light_library", "path", "", "The folder of lighting cue clips (\"30 Blinder Flash 1.alc\"), for the " + "cues' names.", "Ready", + example="~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI") field("ready.tune_track", "str", "WAVES TUNE", "The Ableton track whose MIDI clips set Waves Tune's key per song " "(clips named like \"Ebmaj\").", "Ready") field("ready.tune_library", "path", "", "The folder of Waves Tune key clips (Ebmaj.alc ...), as the tracks computer sees " diff --git a/settings/ui/app.js b/settings/ui/app.js index c789400..b922b38 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -282,7 +282,7 @@ ${chip(w.running, w.running ? "Watchdog ✓" : "Watchdog down", (w.running ? "Running since " + when(w.since) : "Not running") + (w.version ? " · code " + w.version : "") + (o.heartbeat_age != null ? " · heartbeat " + ago(o.heartbeat_age) : ""))} ${chip(o.companion_ok, o.companion_ok ? "Companion ✓" : "Companion down", o.pages && o.pages.status ? (o.pages.status.ok ? "Pages OK" : "Page " + o.pages.status.page + " is called " + (o.pages.status.live || "?")) : "")} ${chip(o.service_on == null ? null : o.service_on, o.service_on == null ? "Alarms ?" : o.service_on ? "Alarms on" : "Alarms silenced", o.phrases.alarms)} - ${chip(null, "Auto-fix " + (af.paused === true ? "paused" : af.on ? "active" : "waiting"), o.auto_fix.phrase + (af.paused === true ? " (paused with the AUTO-FIX key)" : af.net_wait ? " (waiting out a network blip)" : ""))} + ${chip(null, "Auto-fix " + (af.paused === true ? "paused" : af.test_until ? "testing until " + new Date(af.test_until * 1000).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : af.on ? "active" : "waiting"), o.auto_fix.phrase + (af.paused === true ? " (paused with the AUTO-FIX key)" : af.net_wait ? " (waiting out a network blip)" : ""))} ${Object.keys(o.bypass || {}).length ? chip(null, Object.keys(o.bypass).map((k) => ((cfg().tiles.find((t) => t.id === k) || {}).label || k)).join(", ") + ": operator", "Handed to an operator with its key: its MIDI is out until the key is held again") : ""} ${Object.keys(o.running || {}).length ? chip(null, "Fix running", Object.values(o.running).map((r) => r.what + " on " + r.key).join(", ")) : ""} ${o.last_change ? `Last change ${esc(o.last_change.when || "")}` : ""} @@ -292,7 +292,8 @@