diff --git a/ltc_listen.swift b/ltc_listen.swift new file mode 100644 index 0000000..44e4a37 --- /dev/null +++ b/ltc_listen.swift @@ -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.. 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.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? + var usize = UInt32(MemoryLayout?>.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.. 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.size)) == noErr else { return false } + let fmt = input.inputFormat(forBus: 0) + let ch = Int(fmt.channelCount) + decoders = (0.. 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() diff --git a/prop_guard.py b/prop_guard.py index a81faf7..64c3247 100644 --- a/prop_guard.py +++ b/prop_guard.py @@ -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() diff --git a/sites/sc/config.json b/sites/sc/config.json index a5d918b..786b41a 100644 --- a/sites/sc/config.json +++ b/sites/sc/config.json @@ -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": [ diff --git a/tests/ltc_gen.py b/tests/ltc_gen.py new file mode 100644 index 0000000..c886a23 --- /dev/null +++ b/tests/ltc_gen.py @@ -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("