Synchronising Audio and Video in Recordings

A recorder receives two independent RTP streams per participant, each stamped in its own codec clock with its own random starting value, and nothing in those timestamps says the two were captured at the same instant. This guide is part of the Server-Side Recording & Composition guide, and it resolves one problem: how to turn per-SSRC RTP timestamps into a single wall-clock timeline that still holds lip-sync at minute 55 of an hour-long call, and what to do at mux time when a few tens of milliseconds of skew survive anyway.

Context & Trade-offs

An RTP timestamp is a 32-bit counter that ticks at the payload’s clock rate — 90 000 Hz for video, 48 000 Hz for Opus — starting from a value the sender chose at random. Two SSRCs from the same browser tab, capturing the same person, share no base and no rate. Subtracting one from the other is meaningless. The only thing that relates them is the RTCP sender report, which carries a 64-bit NTP timestamp and the RTP timestamp that the sender’s clock read at that same instant. One SR per SSRC gives you one anchor point; the mapping falls straight out of it.

The RTCP sender report is the only link between an RTP clock and wall time A seven-word RTCP sender report layout. The two NTP timestamp words and the RTP timestamp word are highlighted because together they form one anchor pair. A side panel shows the formula converting an arbitrary RTP timestamp into milliseconds of wall clock, plus notes on sender report cadence. RTCP sender report (PT=200) — the anchor pair three of the seven words matter for synchronisation; the rest is loss accounting V=2 P RC PT = 200 (SR) length SSRC of sender — identifies which clock this describes NTP timestamp, seconds since 1900 (high 32 bits) NTP timestamp, fraction of a second (low 32 bits) RTP timestamp of that same instant, in codec ticks sender's packet count sender's octet count Mapping any RTP timestamp wall = ntpRef + (rtpTs - rtpRef) / rate ntpRef = the two purple words, in ms rtpRef = the blue word rate = 90 000 video / 48 000 Opus One anchor per SSRC, every ~5 s Packets before the first SR cannot be placed — buffer, map afterwards Audio and video anchors are read independently, never subtracted Two SSRCs from one participant share a wall clock only after both are mapped. Using the nominal rate forever assumes the sender's crystal is exact. It is not. 50 ppm of crystal error = 180 ms of drift across a 60-minute recording.
Two NTP words plus one RTP word per report; everything downstream is arithmetic on that pair.

The first trade-off is latency versus completeness. RTCP is budgeted at roughly 5% of session bandwidth, so a sender report lands about every 5 seconds and the first one can be several seconds into the stream. A recorder that starts writing immediately has no mapping for its earliest packets. You either buffer 5 seconds of media and map it retroactively, or you start writing with a provisional offset and accept a correction later — which means rewriting timestamps you have already muxed. Buffering is almost always the right call: 5 seconds of decoded audio is trivial, and it removes an entire class of “first ten seconds are out of sync” bugs.

The second trade-off is the nominal rate. Treating 90 000 as exact is fine for a 90-second clip and fatal for a 90-minute one. Consumer capture clocks are typically 20–100 ppm off nominal, and drift is linear: at 50 ppm a video track gains or loses 180 ms per hour against wall time, against a lip-sync budget of roughly 250 ms of A/V offset before viewers notice. Because each participant has their own crystal, in a six-person recording you are correcting six independent slopes, not one. The fix is to stop trusting the nominal rate and measure the real one by least-squares regression across the accumulated sender-report pairs — 12 pairs is a minute of observation and already resolves the slope to a few ppm.

Strategy Anchor used Drift after 60 min Cost
Nominal rate, first SR only 1 pair 100–300 ms none
Re-anchor to latest SR newest pair < 20 ms, but steps timestamp jumps every 5 s
Least-squares over all pairs 12+ pairs 10–30 ms one regression per SR
Regression + slew limit 12+ pairs 10–30 ms, smooth bounded correction rate

One detail catches most implementations by surprise: when the recorder subscribes through an SFU rather than directly to the sender, the sender reports it receives may not be the sender’s own. A forwarding node that rewrites sequence numbers and timestamps — which it must do when it switches a subscriber between simulcast layers, since each layer is a separate encoder with its own timestamp base — has to regenerate the sender report to match the stream it actually emitted. If it forwards the original SR unchanged alongside rewritten RTP, the anchor pair describes a timeline that no longer exists and every mapped timestamp is wrong by whatever offset the rewrite introduced. Confirm which of the two your server does before you trust a single anchor, and treat a fitted rate that lands hundreds of ppm from nominal as evidence of a rewrite mismatch rather than a bad crystal.

Re-anchoring naively to the newest sender report looks attractive — it is one line of code and the residual error is small — but it moves the mapping in discrete steps every 5 seconds, which shows up as a repeating micro-stutter in the muxed file. Regression with a slew limit gives you the accuracy without the steps: compute the ideal offset, then move the applied offset toward it no faster than about 1 ms per second so a correction is spread over time rather than injected as a jump.

Minimal Runnable Implementation

// Per-SSRC clock mapper: RTP ticks -> shared wall clock, with measured rate and unwrap.
const NTP_EPOCH_MS = 2208988800000;   // seconds between 1900-01-01 and the Unix epoch, in ms
const TWO_32 = 4294967296;            // 2^32, the RTP timestamp modulus
const MAX_PPM = 250;                  // reject fits implying a crystal worse than 250 ppm

function ntpToMs(hi, lo) {
  // 64-bit NTP is 32.32 fixed point: integer seconds in hi, fraction of a second in lo
  return hi * 1000 + (lo / TWO_32) * 1000 - NTP_EPOCH_MS;
}

class ClockMapper {
  constructor(nominalRate) {          // 90000 for video, 48000 for Opus
    this.nominalRate = nominalRate;
    this.rate = nominalRate;          // refined from sender reports as they arrive
    this.pairs = [];                  // {wallMs, ticks} anchors, newest last
    this.rollovers = 0;
    this.lastRaw = null;
  }

  unwrap(rtp32) {
    // The 32-bit counter wraps every 13.25 h at 90 kHz — but the base is random, so a
    // stream that started near 2^32 wraps minutes in. Count rollovers explicitly.
    if (this.lastRaw !== null && rtp32 < this.lastRaw && this.lastRaw - rtp32 > TWO_32 / 2) {
      this.rollovers++;
    }
    this.lastRaw = rtp32;
    return this.rollovers * TWO_32 + rtp32;
  }

  onSenderReport(sr) {
    // sr = { ntpHi, ntpLo, rtpTs } straight out of the RTCP SR body
    this.pairs.push({ wallMs: ntpToMs(sr.ntpHi, sr.ntpLo), ticks: this.unwrap(sr.rtpTs) });
    if (this.pairs.length > 24) this.pairs.shift();   // ~2 min window at one SR per 5 s
    if (this.pairs.length >= 4) this.refit();          // need a few pairs before trusting a slope
  }

  refit() {
    // Least squares on ticks-vs-wallMs. The slope IS the sender's true clock rate.
    const n = this.pairs.length;
    const mx = this.pairs.reduce((s, p) => s + p.wallMs, 0) / n;
    const my = this.pairs.reduce((s, p) => s + p.ticks, 0) / n;
    let num = 0, den = 0;
    for (const p of this.pairs) {
      num += (p.wallMs - mx) * (p.ticks - my);
      den += (p.wallMs - mx) ** 2;
    }
    if (den === 0) return;
    const measured = (num / den) * 1000;                        // ticks per second
    const ppm = Math.abs(measured - this.nominalRate) / this.nominalRate * 1e6;
    if (ppm < MAX_PPM) this.rate = measured;                    // ignore obvious outliers
  }

  toWallMs(rtp32) {
    // Anchor on the newest pair, extrapolate with the MEASURED rate, not the nominal one.
    const a = this.pairs[this.pairs.length - 1];
    if (!a) return null;                                        // no SR yet: caller must buffer
    return a.wallMs + ((this.unwrap(rtp32) - a.ticks) / this.rate) * 1000;
  }
}

// Writer side: the timeline must advance even when a participant stops sending.
function fillGap(track, fromMs, toMs) {
  // Audio: emit exact silence for the wall-clock duration, never "wait for the next packet".
  if (track.kind === 'audio') track.writeSilence(Math.round((toMs - fromMs) * 48));  // 48 samples/ms
  // Video: hold the last decoded frame; a black frame is a visible flash on rejoin.
  else track.repeatLastFrame(fromMs, toMs);
}

toWallMs returning null is the case worth designing around rather than papering over. Until the first sender report lands, that SSRC has no position on the shared timeline at all, and there is no honest default — a provisional offset of zero is a guess that will be wrong by however long the sender’s capture pipeline is. Hold those packets in a per-SSRC queue capped at about 6 seconds of media, and drain the queue through the mapper as soon as the first anchor arrives. If the cap is reached with no sender report, that stream is unmappable and should be written as a separate track rather than mixed into a shared timeline you cannot justify. The same queue absorbs the ordinary case of video arriving a few hundred milliseconds before its first RTCP packet, which is otherwise the single most common source of “the first ten seconds are out of sync” reports.

The sequence below is what those calls look like against a real capture: two SSRCs from one participant report independently, the recorder fits each slope separately, and a mid-call drop forces the mapper to start over rather than extend the old fit across the gap.

Sender reports arriving over one minute and the recorder state they produce Three lifelines: a participant's video SSRC, the same participant's audio SSRC, and the recorder. Sender reports arrive alternately every few seconds. After four pairs the recorder fits a rate per SSRC. A media gap at forty seconds forces silence and held frames, and the rejoin with a new SSRC discards the old anchors. One participant, two clocks, one recorder video SSRC 0x4a1 audio SSRC 0x91c recorder SR t=4.2 s rtp=812 004 anchor 1 — rate = nominal SR t=5.1 s rtp=245 760 audio anchored separately SR t=19.3 s (4th pair) fit: 90 004.1 Hz (+46 ppm) SR t=20.1 s (4th pair) fit: 47 999.4 Hz (-12 ppm) media stops t=40.0 s hold frame + write silence rejoin t=43.2 s, SSRC 0x7d3 drop old pairs, re-anchor SR t=48.4 s new base gap closed, 3.2 s padded The two fits differ by 58 ppm — one mapper per SSRC is not optional.
Video and audio anchor independently; a new SSRC after a rejoin invalidates every prior pair.

Reproduction Steps & Debugging Log Patterns

  1. Record a 10-minute call in which one participant plays a clapperboard-style stimulus: a short beep with a simultaneous full-frame white flash, once a minute.
  2. Run ffprobe -show_entries packet=pts_time,stream_index over the finished file and note the audio and video PTS of each flash-beep pair.
  3. Plot the audio-minus-video delta at minute 0, 5 and 10. A flat line is a fixed offset; a straight ramp is uncorrected clock drift; a step is a mishandled gap.
  4. Kill one participant’s connection for 3 seconds mid-recording and confirm the padded silence exactly matches the wall-clock gap, not the packet count.
  5. Re-run the mapper offline against the captured RTCP and compare its fitted rate to the nominal one — anything over 200 ppm means the fit ate an outlier.

Expected healthy log, one line per sender report:

// ssrc=0x4a1 kind=video pairs=4  rate=90004.1  ppm=+46  offsetMs=+3.1
// ssrc=0x91c kind=audio pairs=4  rate=47999.4  ppm=-12  offsetMs=-0.8
// ssrc=0x4a1 kind=video pairs=12 rate=90004.4  ppm=+49  offsetMs=+3.4   // slope stable
// gap ssrc=0x4a1 from=40000ms to=43210ms filled=3210ms frames_held=96
// gap ssrc=0x91c from=40000ms to=43210ms filled=3210ms silence_samples=154080
// slate check: t=0 delta=+11ms  t=300s delta=+14ms  t=600s delta=+16ms

A failing run reads differently in a diagnostic way. If ppm swings between +40 and -180 on consecutive fits, the regression window is too short or a duplicated sender report is being counted twice. If offsetMs climbs monotonically while ppm stays near zero, the mapper is using the nominal rate and ignoring its own fit. If silence_samples is far below 48 * gap_ms, the writer padded by packet count rather than wall clock, and the audio track will finish short — the same failure that Measuring Audio Latency and Jitter Buffer Delay surfaces on the live side. Tag every one of those lines with the participant identifier so a single stream can be followed end to end the way Tracing One Participant Across SFU Logs describes.

When a small offset survives all of that — typically 20–80 ms of capture-pipeline latency the sender never reported — fix it at mux time rather than by fudging the mapper:

# Shift the audio input 120 ms later relative to video. -itsoffset applies to the
# input that FOLLOWS it, and a positive value delays that input.
ffmpeg -i video.mkv -itsoffset 0.120 -i audio.opus \
  -map 0:v -map 1:a -c:v copy -c:a aac -b:a 128k \
  -af aresample=async=1:min_hard_comp=0.100:first_pts=0 \
  -fps_mode cfr -r 30 -movflags +faststart out.mp4

# aresample async=1 stretches or drops audio to track the video clock, correcting
# residual drift the offset alone cannot; min_hard_comp keeps it from reacting to
# anything under 100 ms, which would otherwise chew on normal jitter.
# -copyts is deliberately absent: let ffmpeg rebase to zero after the offset applies.
Choosing a fix from the shape of the measured skew A root measurement box branches into four observed shapes: a constant offset, a linearly growing offset, a single step, and a jump only after a rejoin. Each leads to a distinct fix, from an ffmpeg input offset through rate regression, gap padding, and per-SSRC re-anchoring. The shape of the delta names the bug delta = audio PTS - video PTS sampled at 0 s, mid, end flat, non-zero +60 ms at every sample point linear ramp +10 ms to +190 ms over the hour single step jumps once, then flat again breaks on rejoin fine until a peer reconnects capture latency fix at mux time: -itsoffset 0.060 on the early input crystal skew regress SR pairs; aresample=async=1 mops up the rest unpadded gap pad silence by wall clock, hold frames, never pause the PTS stale anchors new SSRC = new base; clear pairs and wait for a fresh report Only the leftmost branch is safe to fix in ffmpeg alone — the other three are recorder bugs.
Measure the delta three times before reaching for an offset; a ramp is never an offset problem.

Common Implementation Mistakes

FAQ

How much A/V offset can I actually leave in the file?

Roughly 250 ms end to end before lip-sync reads as broken, and viewers are considerably more forgiving of audio lagging video than leading it. Aim for under 40 ms measured at three points across the file so a mid-length recording never approaches the limit.

What if a sender never emits RTCP sender reports?

Then you have no anchor and must fall back to the SFU’s own receive timestamps, which folds network jitter into the timeline. If the forwarding path rewrites timestamps, keep the abs-send-time and playout-delay extensions intact per Rewriting RTP Header Extensions When Forwarding; an SFU that strips them removes the last usable timing signal.

Should the recorder correct drift, or should ffmpeg?

The recorder, for anything that changes over time. -itsoffset applies one constant shift and cannot follow a ramp, so use it only for a fixed capture-latency offset and leave rate correction to the mapper, with aresample=async=1 as a safety net for the last few milliseconds.

Related: return to Server-Side Recording & Composition, and read Compositing Multi-Party Recordings Server-Side for the layout and mixing stage that consumes these timelines, or Selective Forwarding Unit Design for how the streams reach the recorder in the first place.