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
+193
View File
@@ -0,0 +1,193 @@
// ltc_listen: reads the LTC (timecode) the tracks send, from the same audio input ProPresenter's timecode uses
// (Southcoast: the Waves SoundGrid driver), decodes it, and writes what it hears every half second:
// ~/Library/Application Support/status-panel/ltc.json
// {"time": 1793..., "device": "...", "channel": 3, "tc": "01:02:03:04", "last_frame": 1793..., "fps": 30,
// "frames": 1234, "running": true}
// "running": a frame was decoded in the last 0.5 s and the timecode moves forward. Every channel of the device is
// listened to; the one carrying LTC is reported. Built 2026-10-26 for SC, where timecode drops out now and then.
//
// Run with /usr/bin/swift (Apple's, so the microphone permission macOS asks for once stays given when this file
// changes): swift ltc_listen.swift [device UID part, default "WCAudioGridEngine"]
// swift ltc_listen.swift --file test.wav (tests: decode a file, print the timecodes)
import AVFoundation
import CoreAudio
import Foundation
final class LTCDecoder {
var sign = 0 // last level: -1, 0 (unknown), +1
var sinceEdge = 0 // samples since the last transition
var bitPeriod = 20.0 // samples per bit (adapts: 30 fps at 48 kHz is 20)
var halfPending = false // one short interval seen, waiting for its pair
var bits = [UInt8](repeating: 0, count: 80)
var count = 0 // bits received
var peak: Float = 0.05
var onFrame: ((String, Int) -> Void)?
var edges = 0, longs = 0, shorts = 0, gaps = 0 // diagnostics (state["chan"])
func feed(_ x: Float) {
peak = max(peak * 0.9999, abs(x))
let thr = peak * 0.25
sinceEdge += 1
let s = x > thr ? 1 : (x < -thr ? -1 : sign)
if s == sign || s == 0 { return }
if sign == 0 { sign = s; sinceEdge = 0; return }
sign = s
let n = Double(sinceEdge)
sinceEdge = 0
edges += 1
if n > bitPeriod * 1.6 || n < bitPeriod * 0.3 { halfPending = false; gaps += 1; return } // noise or a gap
if n > bitPeriod * 0.75 { // a whole bit with no middle transition: 0
bitPeriod = bitPeriod * 0.95 + n * 0.05
halfPending = false
longs += 1
push(0)
} else if halfPending { // the second half of a 1
bitPeriod = bitPeriod * 0.95 + n * 2 * 0.05
halfPending = false
push(1)
} else {
halfPending = true
shorts += 1
}
}
static let sync: [UInt8] = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1]
func push(_ b: UInt8) {
bits.removeFirst()
bits.append(b)
count += 1
if count < 80 { return }
if Array(bits[64..<80]) != LTCDecoder.sync { return }
func num(_ from: Int, _ n: Int) -> Int {
var v = 0
for i in 0..<n { v |= Int(bits[from + i]) << i }
return v
}
let ff = num(0, 4) + 10 * num(8, 2), ss = num(16, 4) + 10 * num(24, 3)
let mm = num(32, 4) + 10 * num(40, 3), hh = num(48, 4) + 10 * num(56, 2)
if ff > 29 || ss > 59 || mm > 59 || hh > 23 { return }
onFrame?(String(format: "%02d:%02d:%02d:%02d", hh, mm, ss, ff), ((hh * 60 + mm) * 60 + ss) * 30 + ff)
}
}
func findDevice(_ part: String) -> (AudioDeviceID, String)? {
var addr = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size)
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids)
for id in ids {
var uaddr = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyDeviceUID, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var uid: Unmanaged<CFString>?
var usize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
if AudioObjectGetPropertyData(id, &uaddr, 0, nil, &usize, &uid) == noErr, let u = uid?.takeRetainedValue() as String?,
u.contains(part) {
return (id, u)
}
}
return nil
}
let args = CommandLine.arguments
if args.count > 2 && args[1] == "--file" { // tests
let file = try AVAudioFile(forReading: URL(fileURLWithPath: args[2]))
let buf = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: AVAudioFrameCount(file.length))!
try file.read(into: buf)
let dec = LTCDecoder()
dec.onFrame = { tc, _ in print(tc) }
for i in 0..<Int(buf.frameLength) { dec.feed(buf.floatChannelData![0][i]) }
exit(0)
}
let part = args.count > 1 ? args[1] : "WCAudioGridEngine"
let out = (NSHomeDirectory() as NSString).appendingPathComponent("Library/Application Support/status-panel/ltc.json")
var state: [String: Any] = ["time": Date().timeIntervalSince1970, "device": NSNull(), "running": false, "frames": 0]
let lock = NSLock()
var decoders: [LTCDecoder] = []
var lastFrameAt: [Int: Double] = [:], lastIndex: [Int: Int] = [:], movingSince: [Int: Double] = [:]
var frames = 0
var dumpBuf: [Float] = []
func write() {
lock.lock()
let now = Date().timeIntervalSince1970
state["time"] = now
let live = lastFrameAt.filter { now - $0.value < 0.5 }.keys.sorted()
state["running"] = !live.isEmpty
state["frames"] = frames
let data = try? JSONSerialization.data(withJSONObject: state)
lock.unlock()
if let d = data {
try? FileManager.default.createDirectory(atPath: (out as NSString).deletingLastPathComponent, withIntermediateDirectories: true)
try? d.write(to: URL(fileURLWithPath: out + ".tmp"))
rename(out + ".tmp", out) // atomic, and works when there is no file yet
}
}
let engine = AVAudioEngine()
func start() -> Bool {
guard let (dev, uid) = findDevice(part) else { return false }
let input = engine.inputNode
var d = dev
guard let au = input.audioUnit, AudioUnitSetProperty(au, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &d,
UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr else { return false }
let fmt = input.inputFormat(forBus: 0)
let ch = Int(fmt.channelCount)
decoders = (0..<ch).map { c in
let dec = LTCDecoder()
dec.bitPeriod = fmt.sampleRate / 2400.0
dec.onFrame = { tc, index in
lock.lock()
let now = Date().timeIntervalSince1970
lastFrameAt[c] = now
frames += 1
state["channel"] = c + 1
state["tc"] = tc
state["last_frame"] = now
lastIndex[c] = index
lock.unlock()
}
return dec
}
lock.lock(); state["device"] = uid; state["channels"] = ch; lock.unlock()
input.installTap(onBus: 0, bufferSize: 4096, format: fmt) { buf, _ in
guard let data = buf.floatChannelData else { return }
let n = Int(buf.frameLength)
var peak: Float = 0
for c in 0..<min(ch, decoders.count) {
let dec = decoders[c]
let p = data[c]
for i in 0..<n { dec.feed(p[i]); peak = max(peak, abs(p[i])) }
}
lock.lock(); state["peak"] = Double(peak)
var chans: [[String: Any]] = []
for (c, dec) in decoders.enumerated() where dec.edges > 0 {
chans.append(["ch": c + 1, "peak": Double(dec.peak), "edges": dec.edges, "longs": dec.longs, "shorts": dec.shorts,
"gaps": dec.gaps, "bit": dec.bitPeriod, "bits": dec.count])
}
state["chan"] = chans
lock.unlock() // 0 all the time: no audio (or no permission)
let flag = (NSHomeDirectory() as NSString).appendingPathComponent("ltc-dump")
if FileManager.default.fileExists(atPath: flag), let best = decoders.indices.max(by: { decoders[$0].edges < decoders[$1].edges }) {
dumpBuf.append(contentsOf: UnsafeBufferPointer(start: data[best], count: n))
if dumpBuf.count > Int(fmt.sampleRate) * 3 {
let raw = dumpBuf.withUnsafeBufferPointer { Data(buffer: $0) }
try? raw.write(to: URL(fileURLWithPath: "/tmp/ltc_dump_ch\(best + 1)_\(Int(fmt.sampleRate)).f32"))
try? FileManager.default.removeItem(atPath: flag)
dumpBuf = []
}
}
}
do { try engine.start() } catch { return false }
return true
}
while !start() {
write()
Thread.sleep(forTimeInterval: 5) // the SoundGrid driver not there (yet): try again
}
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in write() }
RunLoop.main.run()
+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()
+6
View File
@@ -165,6 +165,12 @@
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
"name": "PROP",
"fix": "propresenter_ingest"
},
{
"type": "http",
"url": "http://127.0.0.1:8095/timecode",
"msg": "TIMECODE LOST",
"interval_seconds": 2
}
],
"pos": [
+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()