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,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
@@ -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()
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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