Skip to content

fix(moq-boy): align audio and video PTS to a single wall-clock reference#1211

Merged
kixelated merged 11 commits intomainfrom
claude/fix-pts-alignment-gspHq
Apr 6, 2026
Merged

fix(moq-boy): align audio and video PTS to a single wall-clock reference#1211
kixelated merged 11 commits intomainfrom
claude/fix-pts-alignment-gspHq

Conversation

@kixelated
Copy link
Copy Markdown
Collaborator

Audio timestamps were computed from a frame counter (frame_count * frame_duration)
while video used start.elapsed(), causing desync after pause/resume cycles.
Now both use the same elapsed Duration captured once per emulator tick.

For audio, when multiple frames are produced in one push, back-date the first
frame so they're evenly spaced leading up to the current timestamp.

https://claude.ai/code/session_015U6uGPsF5nuazT1oUDXpD5

claude added 2 commits April 3, 2026 22:57
Audio timestamps were computed from a frame counter (frame_count * frame_duration)
while video used start.elapsed(), causing desync after pause/resume cycles.
Now both use the same `elapsed` Duration captured once per emulator tick.

For audio, when multiple frames are produced in one push, back-date the first
frame so they're evenly spaced leading up to the current timestamp.

https://claude.ai/code/session_015U6uGPsF5nuazT1oUDXpD5
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

AudioEncoder::push_samples now takes an additional elapsed: std::time::Duration, appends converted PCM into an input_buffer, resamples into an encode_buffer, and encodes fixed-size frames. Per-frame PTS are anchored to a persistent epoch: Option<u64> set when the first full frame is available; encode_frame now accepts an explicit ts_micros: u64. The emulator tick captures a single elapsed = start.elapsed() per iteration and reuses it for video PTS and audio push_samples, calling audio_encoder.reset_epoch() when resuming or when audio becomes active after a gap. The frontend adds a "Buffer" jitter slider and live jitter signal to Watch.Sync. Sync now tracks late-frame batching with internal counters and logs summaries when lateness ends.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: aligning audio and video timestamps to use a common wall-clock reference, which is the core fix addressing the synchronization issue detailed in the summary.
Description check ✅ Passed The description is directly related to the changeset, explaining the specific problem (audio vs video timestamp desync) and the solution (unified elapsed Duration per tick with back-dating for multiple audio frames).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-pts-alignment-gspHq
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/fix-pts-alignment-gspHq

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
rs/moq-boy/src/audio.rs (1)

85-111: LGTM! Clean implementation of wall-clock based audio timestamps.

The back-dating logic correctly spaces frames so the last emitted frame aligns with the current elapsed time, ensuring audio stays synchronized with video. The math is sound:

  • base_ts = elapsed - (pending_frames - 1) * frame_duration_us
  • Each subsequent frame increments by frame_duration_us

One minor defensive consideration: the subtraction at line 100-101 could theoretically underflow if elapsed is very small at startup. In practice this is unlikely since samples accumulate over multiple ticks before a full frame is ready, but using saturating_sub would be more defensive:

🛡️ Optional defensive fix
-		let base_ts = elapsed.as_micros() as u64
-			- pending_frames.saturating_sub(1) as u64 * frame_duration_us;
+		let back_offset = pending_frames.saturating_sub(1) as u64 * frame_duration_us;
+		let base_ts = (elapsed.as_micros() as u64).saturating_sub(back_offset);

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rs/moq-boy/src/audio.rs` around lines 85 - 111, The subtraction used to
compute base_ts in push_samples can underflow when elapsed.as_micros() is
smaller than the back-offset; change the calculation to use saturating_sub on
the elapsed microseconds value (i.e., compute elapsed.as_micros() as u64 and
call .saturating_sub(pending_frames.saturating_sub(1) as u64 *
frame_duration_us)) so base_ts cannot wrap, keeping the existing pending_frames
and frame_duration_us logic and leaving encode_frame/frame_idx logic unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@rs/moq-boy/src/audio.rs`:
- Around line 85-111: The subtraction used to compute base_ts in push_samples
can underflow when elapsed.as_micros() is smaller than the back-offset; change
the calculation to use saturating_sub on the elapsed microseconds value (i.e.,
compute elapsed.as_micros() as u64 and call
.saturating_sub(pending_frames.saturating_sub(1) as u64 * frame_duration_us)) so
base_ts cannot wrap, keeping the existing pending_frames and frame_duration_us
logic and leaving encode_frame/frame_idx logic unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1564ed8e-7e28-4ada-a020-4add6a5f43f6

📥 Commits

Reviewing files that changed from the base of the PR and between c22b43f and ffd09d1.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • rs/moq-boy/src/audio.rs
  • rs/moq-boy/src/main.rs

kixelated and others added 2 commits April 3, 2026 18:12
Merge with main and fix formatting to pass CI checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rs/moq-boy/src/audio.rs`:
- Around line 96-107: The base timestamp calculation can underflow when
elapsed.as_micros() is smaller than the computed offset; update the computation
of base_ts (the line that sets base_ts using elapsed.as_micros() and
pending_frames * frame_duration_us) to use a saturating subtraction so the
result clamps at zero instead of wrapping (apply saturating_sub to the outer
subtraction involving elapsed.as_micros() and pending_frames.saturating_sub(1) *
frame_duration_us); keep the rest of the logic (pending_frames,
frame_duration_us, frame_idx, encode_frame) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5aade3ef-a84b-42f6-b0cf-9464ecf1322f

📥 Commits

Reviewing files that changed from the base of the PR and between ffd09d1 and 41c5c91.

📒 Files selected for processing (2)
  • rs/moq-boy/src/audio.rs
  • rs/moq-boy/src/main.rs

Comment thread rs/moq-boy/src/audio.rs Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
js/moq-boy/src/index.ts (1)

327-330: Consider consolidating the two input event listeners for the jitter slider.

The slider has two separate input listeners—one in #buildControls() (line 439) for the label, and one in the constructor (line 328) for the signal. While functionally correct, consolidating them would reduce duplication.

♻️ Optional: Consolidate listeners

Move the signal update into #buildControls() by accepting the signal as a parameter, or move the label update into the constructor where the signal listener is wired:

 // Wire up jitter slider to sync.
 jitterSlider.addEventListener("input", () => {
   jitter.set(Number.parseInt(jitterSlider.value, 10) as Moq.Time.Milli);
+  // Update label here if you want to consolidate
 });

Also applies to: 439-441

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/moq-boy/src/index.ts` around lines 327 - 330, There are two separate
"input" listeners for jitterSlider (one in the constructor wiring the signal via
jitter.set and another in `#buildControls`() updating the label); consolidate them
into a single listener to avoid duplication by moving the jitter.set call into
`#buildControls`() (accept the jitter signal/object as a parameter) or,
alternatively, move the label update into the constructor where the signal is
wired so the single listener on jitterSlider updates both the label and calls
jitter.set; update references to jitterSlider, jitter.set, and `#buildControls`()
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rs/moq-boy/src/audio.rs`:
- Around line 115-118: The epoch computation can underflow if buffered_us >
elapsed.as_micros(); update the block in the method that sets self.epoch (the
branch checking self.epoch.is_none() && self.encode_buffer.len() >=
samples_per_frame) to compute buffered_us as before but assign self.epoch using
a saturating subtraction of elapsed.as_micros() (cast to u64) and buffered_us
(e.g., use elapsed.as_micros() as u64 .saturating_sub(buffered_us)) so the
timestamp never wraps to a huge u64; keep the same units (OPUS_SAMPLE_RATE and
CHANNELS) and types when computing buffered_us.

---

Nitpick comments:
In `@js/moq-boy/src/index.ts`:
- Around line 327-330: There are two separate "input" listeners for jitterSlider
(one in the constructor wiring the signal via jitter.set and another in
`#buildControls`() updating the label); consolidate them into a single listener to
avoid duplication by moving the jitter.set call into `#buildControls`() (accept
the jitter signal/object as a parameter) or, alternatively, move the label
update into the constructor where the signal is wired so the single listener on
jitterSlider updates both the label and calls jitter.set; update references to
jitterSlider, jitter.set, and `#buildControls`() accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 27800469-7609-4c38-b74b-ffb7897ef8f3

📥 Commits

Reviewing files that changed from the base of the PR and between 41c5c91 and e2175fb.

📒 Files selected for processing (4)
  • js/moq-boy/src/index.ts
  • js/moq-boy/src/styles.ts
  • js/watch/src/sync.ts
  • rs/moq-boy/src/audio.rs
✅ Files skipped from review due to trivial changes (1)
  • js/moq-boy/src/styles.ts

Comment thread rs/moq-boy/src/audio.rs
After a pause/resume, video timestamps jump forward (they use wall-clock
elapsed time), but audio timestamps continued linearly from frame_count.
Now reset_epoch() clears the epoch and stale buffers on resume, so the
next push_samples re-anchors to the current wall clock. This ensures
audio and video PTS stay aligned across pause cycles.

https://claude.ai/code/session_015U6uGPsF5nuazT1oUDXpD5
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rs/moq-boy/src/audio.rs`:
- Around line 100-104: reset_epoch() clears epoch, input_buffer, and
encode_buffer but leaves frame_count, so later code in the packet timestamp
recomputation (uses elapsed, frame_count, and frame_duration_us) re-adds
pre-reset frames causing a timestamp jump; fix by resetting the frame counter
(frame_count = 0) when calling reset_epoch(), or alternatively change the
timestamp recomputation in the packetization logic that uses elapsed, epoch,
frame_count, and frame_duration_us to subtract prior frames (i.e., anchor epoch
to current time without adding previous frame_count * frame_duration_us). Update
references: reset_epoch(), frame_count, frame_duration_us, epoch, input_buffer,
encode_buffer, and the timestamp recomputation block that uses elapsed to ensure
post-reset packets are re-anchored to current wall clock.

In `@rs/moq-boy/src/main.rs`:
- Around line 224-225: Detect the audio-only resume transition (audio_active
going from false to true) and call audio_encoder.reset_epoch() on that first
transition so audio PTS is re-anchored when audio viewers reconnect;
specifically, add the reset_epoch() call inside the audio_active false→true
branch (the same logical area that later calls push_samples() and currently
drops samples) so you reset before discarding/advancing samples, while keeping
the existing reset_epoch() call that runs when the whole emulator unpauses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fedc6a95-0edc-4e67-819a-4fd380dfe0bf

📥 Commits

Reviewing files that changed from the base of the PR and between e2175fb and 73482a3.

📒 Files selected for processing (2)
  • rs/moq-boy/src/audio.rs
  • rs/moq-boy/src/main.rs

Comment thread rs/moq-boy/src/audio.rs
Comment thread rs/moq-boy/src/main.rs
…resume

Two fixes:

1. reset_epoch() now also resets frame_count to 0. Without this, after
   re-anchoring epoch to the current wall clock, the old frame_count
   would add stale offset (e.g. 500 * 20ms = 10s) to the fresh epoch,
   producing timestamps far ahead of actual time.

2. Detect audio false→true transition in the emulator loop and call
   reset_epoch() so audio PTS re-anchors even when only the audio track
   resumes (video was already active, so the full-emulator pause block
   never triggers).

https://claude.ai/code/session_015U6uGPsF5nuazT1oUDXpD5
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rs/moq-boy/src/audio.rs`:
- Around line 96-105: reset_epoch currently clears internal buffers but does not
clear the ffmpeg resampler state, allowing buffered samples to leak after pause;
update reset_epoch (and/or wherever you manage the resampler Context) to call
resampler.flush() (or recreate the ffmpeg_next::software::resampling::Context)
so the resampler's internal delay is cleared; ensure this happens before
returning from reset_epoch so subsequent push_samples() cannot read and emit
pre-pause samples from resampler.delay()/resampler.run().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4ebb35d0-df80-45a4-b2e0-128f7f34d10b

📥 Commits

Reviewing files that changed from the base of the PR and between 73482a3 and 9be136c.

📒 Files selected for processing (2)
  • rs/moq-boy/src/audio.rs
  • rs/moq-boy/src/main.rs

Comment thread rs/moq-boy/src/audio.rs
kixelated and others added 2 commits April 6, 2026 11:30
# Conflicts:
#	js/moq-boy/src/index.ts
#	rs/moq-boy/src/main.rs
- Use saturating_sub when computing epoch to prevent underflow if
  buffered samples exceed elapsed time (CodeRabbit review)
- Flush the ffmpeg resampler's internal delay buffer in reset_epoch()
  so pre-pause samples don't leak into post-pause audio (CodeRabbit review)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kixelated kixelated merged commit a305835 into main Apr 6, 2026
2 checks passed
@kixelated kixelated deleted the claude/fix-pts-alignment-gspHq branch April 6, 2026 19:34
@moq-bot moq-bot bot mentioned this pull request Apr 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants