Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions pkg/synchronizer/ntpestimator.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,15 @@ func NewNtpEstimator(clockRate uint32) *NtpEstimator {
}
}

// Reset clears all state, returning the estimator to its initial condition.
// Used when a stream discontinuity is detected (e.g., stream restart with a new
// RTP offset) and the old regression is no longer valid.
// Reset clears all state including OWD. Called via SessionTimeline.ResetTrack for true stream discontinuities where every prior sample is stale.
func (e *NtpEstimator) Reset() {
e.mu.Lock()
defer e.mu.Unlock()
e.resetLocked()
e.owdEstimator = latency.NewOWDEstimator(latency.OWDEstimatorParamsDefault)
}

// resetLocked clears the regression only; OWD is preserved because outlier-triggered rebuilds don't imply a network change and re-converging OWD would thrash the tempo controller.
func (e *NtpEstimator) resetLocked() {
e.samples = [maxSRSamples]srSample{}
e.sampleLen = 0
Expand All @@ -137,10 +137,6 @@ func (e *NtpEstimator) resetLocked() {
e.residStd = 0
e.ready = false
e.consecutiveOutliers = 0
// Reset OWD: a sender NTP step that triggered the regression rebuild also
// invalidates the previously-measured clock offset, so the estimator must
// re-converge from the new sender state.
e.owdEstimator = latency.NewOWDEstimator(latency.OWDEstimatorParamsDefault)
}
Comment thread
frostbyte73 marked this conversation as resolved.

// SRResult indicates the outcome of processing a sender report.
Expand Down
49 changes: 22 additions & 27 deletions pkg/synchronizer/syncenginetrack.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ const (

// deadbandThreshold is the minimum |correction| before slew smoothing kicks in.
deadbandThreshold = 5 * time.Millisecond

// wallSlewAlpha bounds publisher sample-clock skew: steady-state |drift| = R·δ·(1-α)/α ≈ 10ms for R=20ms, δ=±500ppm, α=0.01.
wallSlewAlpha = 0.01
)

// syncEngineTrack implements TrackSync for a single track within a SyncEngine.
Expand Down Expand Up @@ -425,6 +428,8 @@ func (st *syncEngineTrack) GetPTS(pkt jitter.ExtPacket) (time.Duration, error) {
"deadline", deadline,
"behindBy", limit-oldPTS,
)
// Reset timeliness clock so a lingering transient deficit doesn't fire force-correction on every subsequent packet, collapsing them into the same newPTS at the mixer.
st.lastTimelyPacket = time.Now()

@milos-lk milos-lk Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is one scenario worth zooming in - what happens on the packets after a force-correction, when the track is still behind.. which is the case we kept seeing in cases where carriers don't signal loss correctly (sip) - i.e when the deficit is structural rather than transient.

The jump raises lastPTSAdjusted but the raw baselines (lastWallPTS/lastSlewPTS/sessionOffset) still reflect the behind timeline, so the next packet computes a behind PTS again. With the timer now reset, the monotonicity floor becomes the only thing advancing output - +1ms per 20ms frame, until the timer expires and a multi-second jump fires again.

}
} else {
st.lastTimelyPacket = time.Now()
Expand Down Expand Up @@ -489,43 +494,33 @@ func (st *syncEngineTrack) wallClockPTSForRTP(ts uint32, receivedAt time.Time) (
// wallClockPTSForRTPLocked is the lock-held implementation of wall-clock PTS
// computation. Caller must hold st.mu.
func (st *syncEngineTrack) wallClockPTSForRTPLocked(ts uint32, receivedAt time.Time) time.Duration {
// Defense in depth: if receivedAt is the zero time.Time, wallElapsed
// below would be a ~63-billion-second negative duration, the sanity
// threshold would reject rtpDerived, and the function would return 0
// regardless of sessionOffset — locking the trust-threshold clamp onto
// a bogus baseline. initializeLocked already substitutes time.Now() for
// zero ReceivedAt on the first packet; do the same here in case a later
// caller (e.g., OnRTCP using sr.RTPTime + an unset receivedAt) misses it.
// A zero receivedAt would make wallElapsed hugely negative and lock every downstream clamp onto a bogus baseline.
if receivedAt.IsZero() {
receivedAt = time.Now()
}

// Wall-clock elapsed since this track started, plus session offset
wallElapsed := receivedAt.Sub(st.startTime) + st.sessionOffset
if wallElapsed < 0 {
wallElapsed = 0
}

if !st.initialized {
return wallElapsed
}

// If we have processed at least one packet, use RTP delta from the previous
// pure-wall-clock baseline for more precision (immune to receivedAt jitter).
// uint32 subtraction wraps for backward RTP timestamps; the sanity check
// below catches the resulting huge positive delta and falls back to wall clock.
if st.initialized {
rtpDelta := ts - st.lastTS
rtpDerived := st.lastWallPTS + st.converter.ToDuration(rtpDelta)

// Sanity check: if RTP-derived PTS diverges from wall-clock by > 5s, use wall clock.
diff := rtpDerived - wallElapsed
if diff < 0 {
diff = -diff
}
if diff <= wallClockSanityThreshold {
return rtpDerived
}
}
rtpDelta := ts - st.lastTS
rtpDerived := st.lastWallPTS + st.converter.ToDuration(rtpDelta)
diff := rtpDerived - wallElapsed

// Use wall-clock elapsed, ensuring non-negative.
if wallElapsed < 0 {
wallElapsed = 0
if diff > wallClockSanityThreshold || diff < -wallClockSanityThreshold {
return wallElapsed
}
return wallElapsed

// Slew rtpDerived toward wallElapsed each packet so publisher sample-clock skew stops accumulating across the sanity band; see wallSlewAlpha for the steady-state math.
absorbed := time.Duration(float64(diff) * wallSlewAlpha)

@milos-lk milos-lk Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm - I have a feeling this creates a closed loop with the tempo controller. Let me check it.. The SR drift path reads the slewed baseline, so a delayed packet now enters the tempo loop as phantom drift: tempo books a permanent correction (corrected), the slew then drains the same disturbance back out of the sensor, effective flips sign, and the controller fires counter-corrections undoing its own work, audible ±5% warble from one late packet. Need to check more

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asked claude to check it - I think the graph it generated might be helpful:

return rtpDerived - absorbed
}

// OnSenderReport implements TrackSync. It stores a callback invoked on sender reports.
Expand Down
Loading