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";
}