Timers: real-time frame-accurate LTC display

The LTC card repainted only on server anchors (~4x/s), so the frame
counter jumped in ~7-frame steps. Now the client counts every frame
locally between anchors and re-syncs on each one — frame-accurate and
smooth, with no extra network traffic.

- Server anchors carry ageMs (how old the decoded timecode is) so clients
  can pin the anchor to their own clock. ltc-listener reports it while
  running; composeLtc passes it through (listener) or derives it (reader).
- Client does exact SMPTE frame<->timecode math (parseTc/tcToFrames/
  framesToTc, incl. 29.97 drop-frame minute counting) and advances the
  display in a requestAnimationFrame loop at the LTC's own rate.
- Robust when rAF is throttled (backgrounded tab): render() paints the
  extrapolated frame at the 4x/s anchor rate too, so the display never
  freezes — the rAF loop only refines it to every frame when visible.
- Anchor jitter can't tick the counter backwards (small regressions
  clamped; genuine seeks/re-strikes pass); freshness cap stops freewheel
  into phantom frames when anchors stop arriving.

Verified: SMPTE math against the encoder over 200k drop-frame frames plus
round-trips; the extrapolation pipeline simulated at 30/25/29.97DF showing
strict +1-per-frame advance and correct DF boundary crossing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-10 13:12:52 -04:00
co-authored by Claude Opus 4.8
parent dba10a30fe
commit 7e75146838
3 changed files with 135 additions and 6 deletions
+6 -3
View File
@@ -132,7 +132,7 @@ module.exports = {
if (!ltcEnabled) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
}
const audioVerdict = (src, tag) => ({
const audioVerdict = (src, tag, ageMs) => ({
supported: true,
source: tag,
time: src.time,
@@ -140,16 +140,19 @@ module.exports = {
status: src.state,
fps: src.fps,
df: src.df,
// While running: how old this timecode is, so clients can pin the
// anchor to their own clock and count frames between updates.
...(src.state === 'running' && Number.isFinite(ageMs) ? { ageMs: Math.max(0, ageMs) } : {}),
note: '',
});
// The built-in listener while it has actual data …
if (listenerLtc && !listenerLtc.error && listenerLtc.state !== 'nosignal') {
return audioVerdict(listenerLtc, 'listener');
return audioVerdict(listenerLtc, 'listener', listenerLtc.ageMs);
}
// … else a fresh remote reader (during migrations both may run —
// real data beats no data) …
if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) {
return audioVerdict(ingest, 'reader');
return audioVerdict(ingest, 'reader', Date.now() - ingest.at);
}
// … else a healthy-but-silent listener is believed over the indirect
// sources: no audio means the LTC really stopped.