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)
|
"resume": True, # start a stopped timeline again (False: only log)
|
||||||
"stopped_for": 1.0, # seconds stopped before it counts (ProPresenter blinks while it seeks)
|
"stopped_for": 1.0, # seconds stopped before it counts (ProPresenter blinks while it seeks)
|
||||||
"poll": 0.4,
|
"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")
|
STATUS = os.path.expanduser("~/Library/Application Support/status-panel/prop-guard.json")
|
||||||
log = logging.getLogger("prop-guard")
|
log = logging.getLogger("prop-guard")
|
||||||
@@ -61,7 +64,10 @@ class Guard:
|
|||||||
self.stage_fixed = False
|
self.stage_fixed = False
|
||||||
self.resumed_at = None # when the guard last started the timeline again
|
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.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):
|
def stage(self):
|
||||||
for m in api("/v1/stage/layout_map") or []:
|
for m in api("/v1/stage/layout_map") or []:
|
||||||
@@ -77,9 +83,45 @@ class Guard:
|
|||||||
return False
|
return False
|
||||||
return api("/v1/stage/screen/%s/layout/%s" % (screen["uuid"], want["uuid"])) is not None
|
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):
|
def tick(self):
|
||||||
now = self.now()
|
now = self.now()
|
||||||
pres = (api("/v1/presentation/active") or {}).get("presentation")
|
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 not pres or not pres.get("has_timeline"):
|
||||||
if self.song:
|
if self.song:
|
||||||
log.info("SONG over: %s", self.name)
|
log.info("SONG over: %s", self.name)
|
||||||
@@ -139,6 +181,26 @@ class Guard:
|
|||||||
pass
|
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():
|
def main():
|
||||||
if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
|
if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
|
||||||
CFG.update(json.load(open(sys.argv[1])))
|
CFG.update(json.load(open(sys.argv[1])))
|
||||||
@@ -146,6 +208,10 @@ def main():
|
|||||||
format="%(asctime)s %(levelname)s %(message)s")
|
format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log.info("prop guard started: %s", json.dumps(CFG))
|
log.info("prop guard started: %s", json.dumps(CFG))
|
||||||
g, saved = Guard(), 0.0
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
g.tick()
|
g.tick()
|
||||||
|
|||||||
@@ -165,6 +165,12 @@
|
|||||||
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
|
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
|
||||||
"name": "PROP",
|
"name": "PROP",
|
||||||
"fix": "propresenter_ingest"
|
"fix": "propresenter_ingest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:8095/timecode",
|
||||||
|
"msg": "TIMECODE LOST",
|
||||||
|
"interval_seconds": 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pos": [
|
"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)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user