How does CMTime avoid rounding errors, and how can sloppy conversion make audio and video drift?

18.9.4 · L5 · Interview

Predict first

How far does the rounded sum drift?

import CoreMedia

let frame = CMTime(value: 1001, timescale: 30000)          // one frame at 29.97 fps, exactly
let thirty = CMTimeMultiply(frame, multiplier: 30)
print("30 NTSC frames:", thirty.value, "/", thirty.timescale, "=", thirty.seconds, "s")

let rounded = CMTime(seconds: 1.0 / 29.97, preferredTimescale: 600)
print("one frame stored at timescale 600:", rounded.value, "/", rounded.timescale)
var drifting = CMTime.zero
for _ in 0..<30 { drifting = CMTimeAdd(drifting, rounded) }
print("30 of them added up:", drifting.seconds, "s | equal to the exact 30 frames:", drifting == thirty)
print("drift per 30 frames:", String(format: "%.1f", (thirty.seconds - drifting.seconds) * 1000), "ms")

let oneThird = CMTime(value: 1, timescale: 3)
print("1/3 s as 200/600 compares equal:", CMTimeCompare(oneThird, CMTime(value: 200, timescale: 600)) == 0)
let mixed = CMTimeAdd(CMTime(value: 1, timescale: 44100), CMTime(value: 1, timescale: 48000))
print("1/44100 + 1/48000 uses timescale", mixed.timescale, "(value", mixed.value, ")")

print("CMTime.invalid.isValid:", CMTime.invalid.isValid, "| invalid + 1 s still valid:", CMTimeAdd(.invalid, CMTime(value: 1, timescale: 1)).isValid)
print("CMTime.invalid.seconds is NaN:", CMTime.invalid.seconds.isNaN)

let a = CMTimeRange(start: .zero, duration: CMTime(value: 5, timescale: 1)), b = CMTimeRange(start: CMTime(value: 3, timescale: 1), duration: CMTime(value: 5, timescale: 1))
print("ranges 0..<5 and 3..<8: intersection", a.intersection(b).start.seconds, "+", a.intersection(b).duration.seconds, "| union duration", a.union(b).duration.seconds)
  • 30 exact frames 1.001 s, rounded sum 1.0 s (1.0 ms drift), invalid stays invalid
  • both sums are exactly 1.001 s
  • invalid plus 1 s is valid

Mental model. Counting 30 steps of exactly a metre and a bit versus 30 steps of “about a metre”: each rounded step is fine, the total walks past the mark.

CMTime is a rational number (value ÷ timescale), so 29.97 fps video is exactly 1001/30000 s per frame and 30 frames are exactly 30030/30000 = 1.001 s. The trouble starts when you convert through Double or a coarse timescale. In the check, one frame stored as CMTime(seconds: 1/29.97, preferredTimescale: 600) became 20/600; adding it 30 times gave 1.0 s, 1 ms short of the exact 1.001 s, and the two CMTimes compare unequal. That is about 3.6 seconds of drift per hour of timestamps built this way, enough to push lip-sync visibly out. Mixed timescales are handled for you (1/44100 + 1/48000 used timescale 7,056,000), which is exact but can produce large timescales.

Keep the source timescale (a frame duration from the track, an audio sample rate), multiply an integer frame index by it instead of accumulating rounded steps, and compare with CMTimeCompare or == rather than seconds. Guard invalid values: CMTime.invalid poisons arithmetic (invalid plus 1 s is still invalid) and its seconds is NaN, so check isValid before use. CMTimeRange gives intersection and union for edit lists. Not run: audio–video sync on a real player.

Rational times are exact; rounding frame by frame is not.

Common traps

  • Accumulating rounded frame durations. Multiply index by the exact value.
  • Using an invalid time in arithmetic. It stays invalid silently.

They might follow up with

  • Which timescale for new media? 600 divides common film and video rates, but use the source’s own when you have it.

Related questions

  • 18.9.2 Why does AVAssetImageGenerator return a frame at a different time than you asked for, and how do you get the…
  • 18.1.1 How do AVAsset, AVPlayerItem and AVPlayer fit together, what is CMTime, and why does playback “not start”?
  • 18.1.2 How do AVMutableComposition, tracks and time ranges work for editing, and how do you export the result?

← When do you use AVAssetReader instead of a player, and what do the…   How is a CVPixelBuffer laid out, why are rows padded, and how do… →