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