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:
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user