Files
status-indicators/midi_witness.py
T
christianandClaude Opus 5.5 1f98af4bab 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>
2026-10-26 12:49:06 -04:00

140 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""MIDI witness: writes down the program changes (Waves Tune keys) and other channel messages a Mac really
receives on its network MIDI inputs, so the status panel can check that what the tracks sent arrived.
Runs on the receiving Mac as a LaunchAgent (org.waterschurch.midi-witness), next to Pro Tools: CoreMIDI
gives every input port its own copy, so nothing is taken away from the app that uses the input.
midi_witness.py "Network Tracks Midi" [...] (Studio B: what the NA Tracks Mac sends)
Writes ~/Library/Application Support/status-panel/midi-witness.json once a second when something changed:
{"sources": {"Network Tracks Midi": {"connected": true, "last": 1793..., "count": 12}},
"pcs": [[time, source, channel, program], ...]} (the last 50 program changes)
Built 2026-10-26 for NA: the key Ableton sends must really change Waves Tune at FOH and in Studio B.
"""
import ctypes
import json
import os
import sys
import time
from collections import deque
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import coremidi # noqa: E402
OUT = os.path.expanduser("~/Library/Application Support/status-panel/midi-witness.json")
LENGTHS = {0x80: 3, 0x90: 3, 0xA0: 3, 0xB0: 3, 0xC0: 2, 0xD0: 2, 0xE0: 3}
def messages(data):
"""Channel messages in a packet's bytes: [(status, data bytes)]; system real-time and sysex skipped."""
out, i = [], 0
while i < len(data):
b = data[i]
if b >= 0xF8: # clock, active sensing...
i += 1
elif b == 0xF0: # sysex: to its end
j = data.find(b"\xf7", i)
i = len(data) if j < 0 else j + 1
elif b >= 0xF0:
i += {0xF1: 2, 0xF2: 3, 0xF3: 2}.get(b, 1)
elif b & 0x80:
n = LENGTHS[b & 0xF0]
out.append((b, data[i + 1:i + n]))
i += n
else:
i += 1 # a stray data byte
return out
class Witness:
def __init__(self, names, out=OUT, now=time.time):
self.names, self.out, self.now = names, out, now
self.pcs = deque(maxlen=50)
self.sources = {n: {"connected": False, "last": None, "count": 0} for n in names}
self.endpoints, self.port, self.dirty = {}, None, True
self.proc = coremidi._READPROC(self.on_packets) # keep a reference
self.by_ref = {}
def take(self, name, data):
for status, body in messages(data):
src = self.sources[name]
src["last"], src["count"] = self.now(), src["count"] + 1
if status & 0xF0 == 0xC0 and body:
self.pcs.append([round(self.now(), 3), name, (status & 0x0F) + 1, body[0]])
self.dirty = True
def on_packets(self, pktlist, _ref, src_ref):
try:
name = self.by_ref.get(src_ref or 0)
if name is None:
return
pl = pktlist.contents
addr = ctypes.addressof(pl.packet[0])
for _ in range(pl.numPackets):
pkt = coremidi._MIDIPacket.from_address(addr)
n = min(pkt.length, 256)
self.take(name, bytes(pkt.data[:n]))
addr = (addr + 10 + pkt.length + 3) & ~3 # MIDIPacketNext
except Exception: # noqa: BLE001 - never raise into CoreMIDI's thread
pass
def poll(self):
"""(Re)connect each named source; a network session that comes and goes is picked up again."""
with coremidi._lock:
if self.port is None:
ref = coremidi._U32()
cname = coremidi._cfstr("midi witness")
err = coremidi._cm.MIDIInputPortCreate(coremidi._get_client(), cname, self.proc, None, ctypes.byref(ref))
coremidi._cf.CFRelease(cname)
if err:
raise OSError("MIDIInputPortCreate failed: %d" % err)
self.port = ref.value
found = {}
for i in range(coremidi._cm.MIDIGetNumberOfSources()):
ep = coremidi._cm.MIDIGetSource(i)
for n in self.names:
if n in (coremidi._string_prop(ep, "displayName"), coremidi._string_prop(ep, "name")):
found[n] = ep
for n in self.names:
ep = found.get(n)
if ep == self.endpoints.get(n):
continue
if self.endpoints.get(n) is not None:
coremidi._cm.MIDIPortDisconnectSource(self.port, self.endpoints[n])
self.endpoints[n] = None
if ep is not None:
ref = len(self.by_ref) + 1
self.by_ref[ref] = n
if coremidi._cm.MIDIPortConnectSource(self.port, ep, ctypes.c_void_p(ref)) == 0:
self.endpoints[n] = ep
self.sources[n]["connected"] = self.endpoints[n] is not None
self.dirty = True
def save(self):
if not self.dirty:
return
os.makedirs(os.path.dirname(self.out), exist_ok=True)
with open(self.out + ".tmp", "w") as f:
json.dump({"time": self.now(), "sources": self.sources, "pcs": list(self.pcs)}, f)
os.replace(self.out + ".tmp", self.out)
self.dirty = False
def main():
names = sys.argv[1:] or ["Network Tracks Midi"]
w, polled = Witness(names), 0.0
while True:
if time.time() - polled > 5:
try:
w.poll()
except OSError:
pass
polled = time.time()
w.save()
time.sleep(1)
if __name__ == "__main__":
main()