MIDI: STUDIO B KEY - the Waves Tune key the tracks send really arrives in Studio B
User 2026-10-26: make sure the key Ableton sends changes Waves Tune at FOH and in Studio B. Studio B's Pro Tools can't report the plugin's key, so midi_witness.py (LaunchAgent there) logs every program change on 'Network Tracks Midi' and the watchdog checks it against the song ProPresenter shows (the program that song's Waves Tune clip sends, from the READY look): wrong program, or none after 45 s of a running song, is red. FOH's LiveBox has no network interface: a witness on NA Ingest logs whether it sends anything back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+77
-1
@@ -188,6 +188,8 @@ class Snapshot:
|
||||
self.http = {} # url -> reachable
|
||||
self.disk = {} # path -> free GB
|
||||
self.listeners = {} # MIDI source name -> coremidi.Listener
|
||||
self.witness = {} # host ("" here) -> midi_witness.py's file, with "read": when it was read
|
||||
self.prop_song = None # {"name", "since", "changes"}: the song ProPresenter shows, for the key checks
|
||||
self.remote = {} # (host, command) -> True / False / None (could not ask)
|
||||
self.dante_map = {} # Dante device name -> {"ip", "mac", "switch", "port", "shared", "poe", "seen"}
|
||||
self.dante_map_time = 0.0 # last successful survey of the Dante switches
|
||||
@@ -473,6 +475,78 @@ def check_spotify(chk, snap, ctx):
|
||||
return [("spotify", Result(OK, info="PLAYING"))]
|
||||
|
||||
|
||||
WITNESS_FILE = "Library/Application Support/status-panel/midi-witness.json"
|
||||
|
||||
|
||||
def poll_tune(snap, cfg):
|
||||
"""What each witness Mac received (midi_witness.py) and the song ProPresenter shows now."""
|
||||
hosts = {c.get("host", "") for t in all_tiles(cfg) for c in t.get("checks", []) if c["type"] == "tune_rx"}
|
||||
for host in hosts:
|
||||
try:
|
||||
if host:
|
||||
r = subprocess.run(ssh_cmd(cfg, host) + ["cat '%s'" % WITNESS_FILE], capture_output=True, text=True, timeout=8)
|
||||
data = json.loads(r.stdout) if r.returncode == 0 else None
|
||||
else:
|
||||
with open(os.path.expanduser("~/" + WITNESS_FILE)) as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
data = None
|
||||
if data is not None:
|
||||
data["read"] = time.time()
|
||||
snap.witness = dict(snap.witness, **{host: data})
|
||||
url = ((cfg.get("ready") or {}).get("prop_url") or "").rstrip("/")
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
with urllib.request.urlopen(url + "/v1/presentation/active", timeout=3) as r:
|
||||
pres = (json.loads(r.read() or b"{}") or {}).get("presentation")
|
||||
with urllib.request.urlopen(url + "/v1/presentation/slide_index", timeout=3) as r:
|
||||
idx = ((json.loads(r.read() or b"{}") or {}).get("presentation_index") or {}).get("index")
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
name = ((pres or {}).get("id") or {}).get("name")
|
||||
song = snap.prop_song
|
||||
if not name:
|
||||
snap.prop_song = None
|
||||
elif not song or song["name"] != name:
|
||||
snap.prop_song = {"name": name, "since": time.time(), "changes": 0, "slide": idx}
|
||||
elif idx != song.get("slide"):
|
||||
snap.prop_song = dict(song, changes=song["changes"] + 1, slide=idx)
|
||||
|
||||
|
||||
def check_tune_rx(chk, snap, ctx):
|
||||
"""The key the tracks send (the Waves Tune clip's program at the song's start) really arrived on a Mac (Studio
|
||||
B's Pro Tools), judged against the song ProPresenter shows: a program change received during the song must be
|
||||
the one that song's clip sends; and once a song has run 45 s with its slides moving, one must have come.
|
||||
Only in auto-fix's hours or while a HyperDeck records (2026-10-26)."""
|
||||
name = chk.get("name", "KEY")
|
||||
w = (snap.witness or {}).get(chk.get("host", ""))
|
||||
song = snap.prop_song
|
||||
if not w or ctx["now"] - (w.get("read") or 0) > 30 or not song:
|
||||
return [("tune", Result(OK))]
|
||||
if not (ctx.get("any_recording") or in_windows(ctx.get("autofix_windows") or [], ctx["now"])):
|
||||
return [("tune", Result(OK))]
|
||||
try:
|
||||
import readiness
|
||||
sent = readiness.LAST.get("tune_sent") or {}
|
||||
exp = next((v for k, v in sent.items() if readiness.same(k, song["name"])), None)
|
||||
except Exception: # noqa: BLE001 - no READY page here
|
||||
exp = None
|
||||
if not exp or exp.get("program") is None:
|
||||
return [("tune", Result(OK))]
|
||||
pcs = [p for p in w.get("pcs") or [] if p[0] >= song["since"] - 5]
|
||||
if pcs:
|
||||
got = pcs[-1][3]
|
||||
if got != exp["program"]:
|
||||
return [("tune", Result(ERR, "%s KEY WRONG" % name, info="%s got program %d; %s's clip %s sends %d" % (
|
||||
name, got, song["name"], exp.get("clip"), exp["program"]), named=True))]
|
||||
return [("tune", Result(OK, info="%s KEY %s" % (name, (exp.get("clip") or "").upper())))]
|
||||
if ctx["now"] - song["since"] >= 45 and song.get("changes", 0) >= 2:
|
||||
return [("tune", Result(ERR, "%s GOT NO KEY" % name, info="%s has run %d s and %s received no key (%s)" % (
|
||||
song["name"], ctx["now"] - song["since"], name, exp.get("clip")), named=True))]
|
||||
return [("tune", Result(OK))]
|
||||
|
||||
|
||||
def check_pref(chk, snap, ctx):
|
||||
"""An app setting on NA Ingest, e.g. ATEM Software Control's "Enable MIDI control" must stay off."""
|
||||
value = snap.prefs.get((chk["domain"], chk["key"]))
|
||||
@@ -838,7 +912,7 @@ CHECKS = {
|
||||
"psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd,
|
||||
"process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer, "midi_wired": check_midi_wired, "pref": check_pref, "spotify": check_spotify,
|
||||
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet,
|
||||
"ready": check_ready, "ready_manual": check_ready_manual,
|
||||
"ready": check_ready, "ready_manual": check_ready_manual, "tune_rx": check_tune_rx,
|
||||
}
|
||||
|
||||
|
||||
@@ -3818,6 +3892,8 @@ def start_pollers(cfg, snap, stop, data_dir=None):
|
||||
Periodic("remote", 1.0, lambda: poll_remote(snap, cfg, last=_remote_last), stop), # runs the groups that are due
|
||||
Periodic("dante-map", 30.0, lambda: poll_dante_map(snap, cfg, c4, dante_map_path(data_dir)), stop),
|
||||
]
|
||||
if any(c["type"] == "tune_rx" for t in all_tiles(cfg) for c in t.get("checks", [])):
|
||||
threads.append(Periodic("tune-witness", 2.0, lambda: poll_tune(snap, cfg), stop))
|
||||
for t in threads:
|
||||
t.start()
|
||||
threads += start_remote_midi_streams(snap, cfg, stop)
|
||||
|
||||
Reference in New Issue
Block a user