using System; using System.Diagnostics; namespace ytLive.Helpers; /// /// Arrival cadence for one window of a timestamped frame stream: how many frames /// arrived, how far apart they were, and how many gaps were long enough to read /// as a hitch. Fed any shared monotonic clock ( /// values, or a device's system-relative stamps on the same clock) and read through /// . /// /// Why it exists: a 60fps container makes a missing camera frame indistinguishable /// from a padded repeat. rawvideo carries no per-frame timestamps — ffmpeg stamps /// frames by ARRIVAL at the declared rate — so the OBS "duplicate on lag" loop /// (FramePump) emits 300 authored frames per 5s whether the renderer managed 300 /// composites or 84. The recorded file therefore cannot name the stage that lost /// the frames; only these counters can. The same counter run twice tells the two /// apart: the camera's OWN timestamps vs our ARRIVAL timestamps. Steady camera /// stamps with ragged arrivals means WE dropped it (compositor/reader); ragged in /// both means the camera, USB link, or driver did. /// public sealed class FrameCadence { /// Default hitch threshold in ms. 50ms is ~1.5x a 30fps period — a /// missed 30fps frame arrives as ~66ms — so this counts dropped frames without /// flagging ordinary jitter (a 33ms frame can legitimately measure 35ms). public const double DefaultHitchThresholdMs = 50; private readonly double _hitchThresholdMs; private long _firstTicks; private long _previousTicks; private double _gapTicksTotal; private int _frames; private int _gaps; private int _hitches; private double _longestGapMs; public FrameCadence(double hitchThresholdMs = DefaultHitchThresholdMs) { if (hitchThresholdMs <= 0) throw new ArgumentOutOfRangeException(nameof(hitchThresholdMs), hitchThresholdMs, "Hitch threshold must be positive."); _hitchThresholdMs = hitchThresholdMs; } public int Frames => _frames; public int Gaps => _gaps; public int Hitches => _hitches; public double HitchThresholdMs => _hitchThresholdMs; /// Records one frame at on the shared /// monotonic clock. Monotonicity is asserted, not trusted: an out-of-order stamp /// (a device whose clock domain differs from ours, a wrapped counter, a dropped /// timestamp) is counted as a frame but produces no gap AND does not become the /// reference for the next one — otherwise a single bad stamp would poison both /// its own interval and the one after it, inventing a multi-second "hitch". public void Add(long timestampTicks) { if (_frames == 0) { _firstTicks = timestampTicks; _previousTicks = timestampTicks; _frames++; return; } var delta = timestampTicks - _previousTicks; _frames++; if (delta <= 0) return; var gapMs = delta * 1000d / Stopwatch.Frequency; _gaps++; _gapTicksTotal += gapMs; if (gapMs > _longestGapMs) _longestGapMs = gapMs; if (gapMs >= _hitchThresholdMs) _hitches++; _previousTicks = timestampTicks; } public FrameCadenceSummary Summarize() { var spanMs = _frames >= 2 ? (_previousTicks - _firstTicks) * 1000d / Stopwatch.Frequency : 0d; // Rate is gaps-per-span, not frames-per-span: N frames span N-1 periods, and // counting the extra interval would report one extra frame per window. var rate = _gaps > 0 && spanMs > 0 ? _gaps / (spanMs / 1000d) : 0d; return new FrameCadenceSummary( _frames, rate, spanMs, _gaps, _hitches, _longestGapMs, _gaps > 0 ? _gapTicksTotal / _gaps : 0d, _hitchThresholdMs); } /// Starts a fresh window, keeping the configured threshold. public void Reset() { _firstTicks = 0; _previousTicks = 0; _gapTicksTotal = 0; _frames = 0; _gaps = 0; _hitches = 0; _longestGapMs = 0; } } /// One measured cadence window — see . public readonly record struct FrameCadenceSummary( int Frames, double RatePerSecond, double SpanMs, int Gaps, int Hitches, double LongestGapMs, double AverageGapMs, double HitchThresholdMs) { /// One-line log form. Identical in shape from every producer (camera /// source, frame pump) so two lines can be read against each other: same window /// length, same fields, same order. public string Describe(string label) => Frames == 0 ? $"{label} no frames" : $"{label} {Frames}f/{SpanMs / 1000d:F1}s {RatePerSecond:F1}/s, {Gaps} gaps, " + $"{Hitches} >= {HitchThresholdMs:F0}ms, avg {AverageGapMs:F1}ms, longest {LongestGapMs:F1}ms"; }