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:
+67
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user