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
+42
View File
@@ -0,0 +1,42 @@
"""Writes a test LTC file (biphase mark, 30 fps, 48 kHz) for ltc_listen.swift's decoder test."""
import struct
import sys
import wave
def frame_bits(h, m, s, f):
bits = [0] * 80
def put(v, at, n):
for i in range(n):
bits[at + i] = (v >> i) & 1
put(f % 10, 0, 4); put(f // 10, 8, 2); put(s % 10, 16, 4); put(s // 10, 24, 3)
put(m % 10, 32, 4); put(m // 10, 40, 3); put(h % 10, 48, 4); put(h // 10, 56, 2)
bits[64:80] = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1]
return bits
def write(path, start=(1, 2, 3, 4), seconds=2, rate=48000, fps=30, amp=0.5):
h, m, s, f = start
per_bit = rate / (fps * 80.0)
level, t, samples = 1, 0.0, []
for _ in range(seconds * fps):
for b in frame_bits(h, m, s, f):
level = -level # a transition at every bit start
half = per_bit / 2
for k in range(2):
if k == 1 and b:
level = -level # and one in the middle of a 1
t += half
while len(samples) < int(round(t)):
samples.append(level * amp)
f += 1
if f == fps:
f, s = 0, s + 1
w = wave.open(path, "wb")
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
w.writeframes(b"".join(struct.pack("<h", int(x * 32767)) for x in samples))
w.close()
if __name__ == "__main__":
write(sys.argv[1])
+34
View File
@@ -88,5 +88,39 @@ class TestGuard(unittest.TestCase):
self.assertEqual(self.fake.calls.count("/v1/presentation/active/timeline/play"), 1)
class TestTimecode(unittest.TestCase):
def test_timecode_lost_in_a_song_and_back(self):
fake, clock = FakeProP(), [1000.0]
fake.layout = "Worship"
orig_api, orig_ltc = pg.api, pg.Guard.ltc
ltc = {"running": True, "tc": "00:01:00:00"}
pg.api = fake
pg.Guard.ltc = lambda self: dict(ltc, time=clock[0])
try:
g = pg.Guard(now=lambda: clock[0])
def at(t):
clock[0] = 1000.0 + t
g.tick()
at(0)
at(5)
ltc["running"] = False # gone mid-song
at(6)
self.assertFalse(g.lost) # not yet 2 s
at(8.5)
self.assertTrue(g.lost)
self.assertEqual(g.state["ltc_drops"][0]["song"], "Jehovah")
ltc["running"] = True
at(10)
self.assertFalse(g.lost)
self.assertIn("back", g.state["ltc_drops"][0])
fake.song = None # between songs no timecode is fine
ltc["running"] = False
at(20)
at(30)
self.assertFalse(g.lost)
finally:
pg.api, pg.Guard.ltc = orig_api, orig_ltc
if __name__ == "__main__":
unittest.main()