SC timecode: LTC Listen.app decodes the tracks' LTC from SoundGrid; PROP shows TIMECODE LOST mid-song

ProPresenter can't report its timecode, so LTC Listen (a small app on SC's ProPresenter Mac, microphone
permission allowed on site 2026-10-26, LaunchAgent org.waterschurch.ltc-listen) reads the same SoundGrid
input (com_waves_WCAudioGridEngine, channel 1) and writes the running timecode. The prop guard logs a drop
in a song (2 s without timecode) with the song and timecode, and serves 127.0.0.1:8095/timecode, which SC's
PROP key checks (TIMECODE LOST). Decoder tested against generated LTC and live (00:27:37:28).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 14:22:07 -04:00
co-authored by Claude Opus 5.5
parent e191150f7a
commit a3b38cbd5d
5 changed files with 342 additions and 1 deletions
+67 -1
View File
@@ -35,6 +35,9 @@ CFG = {
"resume": True, # start a stopped timeline again (False: only log)
"stopped_for": 1.0, # seconds stopped before it counts (ProPresenter blinks while it seeks)
"poll": 0.4,
"ltc_file": "~/Library/Application Support/status-panel/ltc.json", # LTC Listen.app: the timecode really arriving
"ltc_lost_after": 2.0, # seconds without timecode in a song that had it: TIMECODE LOST
"status_port": 8095, # GET /timecode: 200 fine, 503 lost (the status panel's http check)
}
STATUS = os.path.expanduser("~/Library/Application Support/status-panel/prop-guard.json")
log = logging.getLogger("prop-guard")
@@ -61,7 +64,10 @@ class Guard:
self.stage_fixed = False
self.resumed_at = None # when the guard last started the timeline again
self.hands_off = False # stopped again right after a restart: someone means it, leave this song alone
self.state = {"started": now(), "timecode_drops": [], "stage_fixes": [], "now": {}}
self.state = {"started": now(), "timecode_drops": [], "stage_fixes": [], "ltc_drops": [], "now": {}}
self.ltc_seen = False # timecode heard during this song
self.ltc_lost_at = None # when it stopped (None: running, or not heard yet this song)
self.lost = False # TIMECODE LOST, for the status panel
def stage(self):
for m in api("/v1/stage/layout_map") or []:
@@ -77,9 +83,45 @@ class Guard:
return False
return api("/v1/stage/screen/%s/layout/%s" % (screen["uuid"], want["uuid"])) is not None
def ltc(self):
try:
with open(os.path.expanduser(CFG["ltc_file"])) as f:
d = json.load(f)
except (OSError, ValueError):
return None
return d if self.now() - (d.get("time") or 0) < 5 else None # LTC Listen not writing: no verdict
def watch_ltc(self, name, now):
"""LTC Listen hears the tracks' timecode on the same SoundGrid input ProPresenter uses. In a song that had it,
timecode gone for ltc_lost_after seconds is TIMECODE LOST (logged with where it stopped) until it comes back."""
d = self.ltc()
if d is None or name is None:
self.ltc_seen, self.ltc_lost_at, self.lost = False, None, False
return
if d.get("running"):
if self.lost:
log.warning("TIMECODE BACK in %s after %.1f s at %s", name, now - self.ltc_lost_at, d.get("tc"))
self.state["ltc_drops"][-1]["back"] = now
self.ltc_seen, self.ltc_lost_at, self.lost = True, None, False
self.last_tc = d.get("tc")
return
if not self.ltc_seen:
return
self.ltc_lost_at = self.ltc_lost_at or now
if not self.lost and now - self.ltc_lost_at >= CFG["ltc_lost_after"]:
self.lost = True
log.warning("TIMECODE DROPPED in %s at %s: no timecode on SoundGrid for %.1f s", name, getattr(self, "last_tc", "?"),
now - self.ltc_lost_at)
self.state["ltc_drops"] = (self.state["ltc_drops"] + [{"song": name, "tc": getattr(self, "last_tc", None),
"time": self.ltc_lost_at}])[-20:]
def tick(self):
now = self.now()
pres = (api("/v1/presentation/active") or {}).get("presentation")
song_name = pres["id"]["name"] if pres and pres.get("has_timeline") else None
if song_name != getattr(self, "ltc_song", None):
self.ltc_seen, self.ltc_lost_at, self.lost, self.ltc_song = False, None, False, song_name
self.watch_ltc(song_name, now)
if not pres or not pres.get("has_timeline"):
if self.song:
log.info("SONG over: %s", self.name)
@@ -139,6 +181,26 @@ class Guard:
pass
def serve_status(guard):
"""GET /timecode: 200 "RUNNING" / "OK", or 503 "TIMECODE LOST" (SC's status panel checks it)."""
import http.server
import threading
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
lost = guard.lost
body = b"TIMECODE LOST" if lost else b"OK"
self.send_response(503 if lost else 200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
srv = http.server.ThreadingHTTPServer(("127.0.0.1", CFG["status_port"]), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
def main():
if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
CFG.update(json.load(open(sys.argv[1])))
@@ -146,6 +208,10 @@ def main():
format="%(asctime)s %(levelname)s %(message)s")
log.info("prop guard started: %s", json.dumps(CFG))
g, saved = Guard(), 0.0
try:
serve_status(g)
except OSError as e:
log.warning("status port %s: %s", CFG["status_port"], e)
while True:
try:
g.tick()