Watch Pro Tools Solo Mode (AFL + Latch) on the Studio B Mac Studio

A LaunchAgent helper on that Mac (Accessibility) reads Options > Solo Mode
every second into a status file and ticks AFL/Latch on request. APPS
alarms PT SOLO NOT AFL (hold fixes it; in the Sunday auto-fix list) and
PT SOLO CHECK OFF when the helper is not running or not allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 09:26:26 -04:00
co-authored by Claude Opus 5.5
parent 3649fb269f
commit 0a4352bb92
5 changed files with 136 additions and 3 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ The **page number key** on every page follows the ALL button. It's green when ev
| LED | VX1000 and UHD Jr: Companion connection **and** each one answers ping (the NovaStar module keeps saying "Connected" after a processor is switched off). "UHDJR NO PING" = processor off or unreachable; "UHDJR OFFLINE" = Companion's connection failed; "UHDJR DISABLED" (amber) = connection turned off in Companion. |
| DANTE | All 19 Dante devices ("19/19"), named when one drops: "WING GONE", "TRACKS GONE", "PROP MAC GONE". |
| MIDI | All six network MIDI links on the FOH / Studio B checklist, each named when it drops: **LIGHT PC** (Ingest → Lighting), **SUPERRACK** (Ingest → Livebox), **FX LIGHTS** (Tracks → Lighting, the FX lighting cues), **TRK LIVEBOX** (Tracks → Livebox), **TRK STUDIO B** (Tracks → Studio B), **PROP LIGHTS** (ProPresenter → Lighting). NA Ingest's own session is read locally; NA Tracks' and NA ProP's over SSH every 5 s. Also: the Lighting PC and NA Tracks MIDI sessions still announced on the network. A clean disconnect shows within about 10 s; a crash or pulled cable takes about a minute (macOS's own timeout, measured). Hold 2 s reconnects whichever links dropped. |
| APPS | ProPresenter API (offline or error only: its harmless yellow warning, every 15–20 min, is ignored with `"ignore_warning": true`); Ableton OSC; tracks Mac pings; Bible TV app answering on :8080 (every 5 s); Bible TV OSC. Messages name the app ("PROPRES OFFLINE", "BIBLE APP DOWN"). Hold 2 s restarts the ProPresenter, Ableton and Bible TV Companion connections. |
| APPS | ProPresenter API (offline or error only: its harmless yellow warning, every 15–20 min, is ignored with `"ignore_warning": true`); Ableton OSC; tracks Mac pings; Bible TV app answering on :8080 (every 5 s); Bible TV OSC. Messages name the app ("PROPRES OFFLINE", "BIBLE APP DOWN"). Hold 2 s restarts the ProPresenter, Ableton and Bible TV Companion connections.; **Pro Tools on the Studio B Mac Studio** set to Solo Mode **AFL + Latch** ("PT SOLO NOT AFL"; hold, or Sunday auto-fix, ticks them back). A helper on that Mac (`remote-access/solo-mode-agent.swift`, installed with `install-solo-mode.sh`) reads the menu; it needs Accessibility allowed once, then a restart of the helper. "PT SOLO CHECK OFF" = that helper is not running or not allowed. |
| VISTA | Vista on the Lighting PC: its Art-Net poll (sent every 0.5 s) must be heard within 5 s, otherwise **SILENT** (Vista closed or crashed, or the Lighting PC is off the network). Hold 2 s starts Vista if it is not running; Vista then opens **Prop Takeover** from its show folder (`E:\media\Documents\Vista 3\Shows`) by itself, through its own autoload preference (`<autoloadpath>` in `E:\media\Documents\Vista 3\.preferences`, set 2026-10-08). Resolume is the ARENA button. |
| RESI | decoder (RossTalk connection and ping, 10.1.10.203); **encoder** (ping and its web page, 10.1.10.202, LAOEncoder); resi-studio-cues; Resi cloud reachable. Only shows they are on the network, not that the encoder is streaming. |
| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21). A blip **flashes amber** for 5 minutes, then goes by itself; press NET to stop the flashing, hold NET 2 s to clear it now. |
+7 -2
View File
@@ -34,6 +34,7 @@
"translation": { "ssh": "studio@10.1.7.39" },
"bibletv": { "ssh": "media@10.6.7.13" },
"lighting": { "ssh": "media@10.1.10.206" },
"studiob": { "ssh": "studiob@10.1.13.175" },
"livestream": { "ssh": "media@10.1.9.126", "mac": "6c:24:08:d7:fb:57", "wake_broadcast": "10.1.255.255" }
},
@@ -64,6 +65,8 @@
"run": "(sc query rtpMIDIService | find \"RUNNING\" >nul || net start rtpMIDIService) && (tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul || schtasks /Run /TN \"\\StatusPanel\\Start rtpMIDI\")" },
"midi_reconnect": { "kind": "midi_connect", "short": "RECONNECT", "label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup); the address is where it was last seen, else the check's \"address\"" },
"caption_screen": { "kind": "local", "short": "CAPTION SCREEN", "label": "restart the lobby caption screen (display app only)", "run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions-display || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions-display.plist" },
"solo_afl": { "host": "studiob", "short": "PT SOLO", "label": "set Pro Tools on the Studio B Mac Studio back to Solo Mode AFL + Latch (clicks Options > Solo Mode)",
"run": "touch \"$HOME/Library/Application Support/status-panel/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"" },
"proddash": { "kind": "local", "short": "PRODDASH", "label": "restart ProdDash (its launcher stops its server cleanly)",
"run": "pkill -TERM -x ProdDash; i=0; while pgrep -xq ProdDash && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; open -g /Applications/ProdDash.app" },
"prodcom": { "kind": "local", "short": "PRODCOM", "label": "restart ProdCom (transcription; lobby captions blank for 5-15 s)",
@@ -83,7 +86,7 @@
"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
"restart_connections": true,
"fixes": ["hyperdeck", "tascam_reconnect", "midi_reconnect", "rtpmidi_start", "vista", "arena", "obs",
"ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation"]
"ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation", "solo_afl"]
},
"restart_page": {
@@ -162,7 +165,9 @@
{ "type": "ping", "host": "10.1.3.227", "name": "TRACKS MAC" },
{ "type": "http", "url": "http://10.1.14.225:1025/", "msg": "PROP TV DOWN", "interval_seconds": 5, "fix": "propresenter" },
{ "type": "http", "url": "http://10.6.7.13:8080/", "msg": "BIBLE APP DOWN", "interval_seconds": 5, "fix": "bibleapp" },
{ "type": "conn", "conn": "BibleTV", "tag": "BIBLE OSC" }
{ "type": "conn", "conn": "BibleTV", "tag": "BIBLE OSC" },
{ "type": "remote", "host": "studiob", "cmd": "! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"", "msg": "PT SOLO NOT AFL", "fix": "solo_afl" },
{ "type": "remote", "host": "studiob", "cmd": "f=\"$HOME/Library/Application Support/status-panel/solo-mode.json\"; [ $(( $(date +%s) - $(stat -f %m \"$f\") )) -lt 20 ] && grep -q '\"trusted\":true' \"$f\"", "msg": "PT SOLO CHECK OFF" }
] },
{ "id": "service", "label": "ALARMS", "pos": [3, 0], "kind": "service" },
+27
View File
@@ -0,0 +1,27 @@
#!/bin/zsh
# Install (or update) the Pro Tools solo-mode helper on the Studio B Mac Studio, as its logged-in user.
# Run from NA Ingest:
# scp remote-access/solo-mode-agent.swift remote-access/install-solo-mode.sh studiob@10.1.13.175:/tmp/
# ssh studiob@10.1.13.175 'zsh /tmp/install-solo-mode.sh'
# Then allow "solo-mode-agent" once in System Settings > Privacy & Security > Accessibility on that Mac.
set -e
DIR="$HOME/Library/Application Support/status-panel"
LABEL=org.waterschurch.status-panel.solo-mode
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
mkdir -p "$DIR" "$HOME/Library/LaunchAgents"
/usr/bin/swiftc -O "${0:A:h}/solo-mode-agent.swift" -o "$DIR/solo-mode-agent"
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key><array><string>$DIR/solo-mode-agent</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
</dict></plist>
EOF
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
for i in {1..50}; do launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || break; sleep 0.2; done
launchctl bootstrap "gui/$(id -u)" "$PLIST"
echo "installed $LABEL"
+87
View File
@@ -0,0 +1,87 @@
// Status panel helper for the Studio B Mac Studio: watches Pro Tools' Options > Solo Mode menu.
//
// Every second it writes ~/Library/Application Support/status-panel/solo-mode.json:
// {"time": ..., "running": true, "trusted": true, "afl": true, "latch": true, "bad": false, "error": ""}
// "bad" is true only when Pro Tools is running and AFL or Latch is not ticked.
// If ~/Library/Application Support/status-panel/solo-mode.fix appears, it ticks what is missing
// (presses the menu items, as a person would) and removes the file.
//
// It reads and presses menus through Accessibility, so macOS asks once:
// System Settings > Privacy & Security > Accessibility > allow "solo-mode-agent".
// Installed as the LaunchAgent org.waterschurch.status-panel.solo-mode by remote-access/install-solo-mode.sh.
import Cocoa
import ApplicationServices
let dir = NSString(string: "~/Library/Application Support/status-panel").expandingTildeInPath
let stateFile = dir + "/solo-mode.json"
let fixFile = dir + "/solo-mode.fix"
func attr(_ e: AXUIElement, _ a: String) -> AnyObject? {
var v: AnyObject?
return AXUIElementCopyAttributeValue(e, a as CFString, &v) == .success ? v : nil
}
func children(_ e: AXUIElement) -> [AXUIElement] { (attr(e, kAXChildrenAttribute) as? [AXUIElement]) ?? [] }
func title(_ e: AXUIElement) -> String { (attr(e, kAXTitleAttribute) as? String) ?? "" }
func items(of e: AXUIElement) -> [AXUIElement] { children(e).flatMap { children($0) } } // item -> menu -> items
func ticked(_ e: AXUIElement) -> Bool { !((attr(e, kAXMenuItemMarkCharAttribute) as? String) ?? "").isEmpty }
func soloItems() -> (afl: AXUIElement?, latch: AXUIElement?, error: String) {
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.avid.ProTools").first else {
return (nil, nil, "Pro Tools not running")
}
let ax = AXUIElementCreateApplication(app.processIdentifier)
guard let bar = attr(ax, kAXMenuBarAttribute) else { return (nil, nil, "no menu bar") }
guard let options = children(bar as! AXUIElement).first(where: { title($0) == "Options" }) else {
return (nil, nil, "no Options menu")
}
guard let solo = items(of: options).first(where: { title($0) == "Solo Mode" }) else {
return (nil, nil, "no Solo Mode menu")
}
let modes = items(of: solo)
return (modes.first { title($0).hasPrefix("AFL") }, modes.first { title($0) == "Latch" }, "")
}
func write(_ state: [String: Any]) {
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
if let data = try? JSONSerialization.data(withJSONObject: state, options: [.sortedKeys]) {
let tmp = stateFile + ".tmp"
FileManager.default.createFile(atPath: tmp, contents: data)
_ = try? FileManager.default.replaceItemAt(URL(fileURLWithPath: stateFile), withItemAt: URL(fileURLWithPath: tmp))
}
}
// Ask once for Accessibility (shows macOS's own prompt on this Mac's screen).
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
_ = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
while true {
autoreleasepool {
var state: [String: Any] = ["time": Date().timeIntervalSince1970, "trusted": AXIsProcessTrusted()]
let running = !NSRunningApplication.runningApplications(withBundleIdentifier: "com.avid.ProTools").isEmpty
state["running"] = running
var bad = false
if running && AXIsProcessTrusted() {
var found = soloItems()
if FileManager.default.fileExists(atPath: fixFile) {
try? FileManager.default.removeItem(atPath: fixFile)
if let a = found.afl, !ticked(a) { AXUIElementPerformAction(a, kAXPressAction as CFString) }
Thread.sleep(forTimeInterval: 0.3)
found = soloItems()
if let l = found.latch, !ticked(l) { AXUIElementPerformAction(l, kAXPressAction as CFString) }
Thread.sleep(forTimeInterval: 0.3)
found = soloItems()
}
if let a = found.afl, let l = found.latch {
state["afl"] = ticked(a)
state["latch"] = ticked(l)
bad = !(ticked(a) && ticked(l))
}
state["error"] = found.error
} else {
state["error"] = running ? "Accessibility not allowed yet" : "Pro Tools not running"
}
state["bad"] = bad
write(state)
}
Thread.sleep(forTimeInterval: 1.0)
}
+14
View File
@@ -264,6 +264,17 @@ class TestChecks(unittest.TestCase):
self.assertEqual(tiles["apps"]["x"], "APPS\nPROP TV DOWN")
self.assertEqual(dict(sp.http_checks(CONFIG))["http://10.1.14.225:1025/"], 5)
def test_pro_tools_solo_mode_on_studio_b(self):
chk = [c for c in CONFIG["tiles"][[t["id"] for t in CONFIG["tiles"]].index("apps")]["checks"]
if c.get("msg") == "PT SOLO NOT AFL"][0]
self.snap.remote["studiob\0" + chk["cmd"]] = False
engine = sp.Engine(CONFIG)
tiles, _ = run_steps(engine, self.snap, self.now, 4)
self.assertEqual(tiles["apps"]["x"], "APPS\nPT SOLO NOT AFL")
self.assertEqual(engine.fix_plan("apps", self.snap)[0][1:3], ("fix", "solo_afl"))
self.assertIn("solo_afl", CONFIG["auto_fix"]["fixes"])
self.assertIn('"bad":true', chk["cmd"])
def test_propresenter_yellow_warning_is_ignored(self):
engine = sp.Engine(CONFIG)
self.snap.conns["Propresenter-API"].update(category="warning", message="Socket closed")
@@ -1175,8 +1186,10 @@ class TestFixes(unittest.TestCase):
self.assertEqual((tiles["dante"]["x"], level(tiles["dante"])), ("DANTE\nFIXING\nDVS", "fixing"))
gate.set()
time.sleep(0.2)
self.snap.conns_time = time.time() # fresh data at the real clock
tiles = self.engine.step(self.snap, time.time())
self.assertEqual((tiles["dante"]["x"], level(tiles["dante"])), ("DANTE\nCHECKING\nDVS", "fixing"))
self.snap.conns_time = time.time() + 25 # fresh data at the real clock
tiles = self.engine.step(self.snap, time.time() + 25) # still gone: back to the alarm
self.assertEqual(tiles["dante"]["x"], "DANTE\nPROP MAC GONE")
# held again within a minute
@@ -1186,6 +1199,7 @@ class TestFixes(unittest.TestCase):
outcome["ok"] = False
self.engine.run_fixes("dante", self.snap, None, t + 100)
time.sleep(0.2)
self.snap.conns_time = time.time() # fresh data at the real clock
tiles = self.engine.step(self.snap, time.time())
self.assertEqual((tiles["dante"]["x"], level(tiles["dante"])), ("DANTE\nFIX FAILED\nDVS", "failed"))