Skip to content

chore(quest): plan the native playout clock - #3398

Merged
kixelated merged 3 commits into
devfrom
claude/quest-playout-clock
Sep 4, 2026
Merged

chore(quest): plan the native playout clock#3398
kixelated merged 3 commits into
devfrom
claude/quest-playout-clock

Conversation

@kixelated

@kixelated kixelated commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds quest/m0/playout-clock.md, scoped through a /plan-quest interview and then corrected against a Codex review that caught real errors in it.

moq play has no playout offset anywhere in its path, and two clocks that disagree about what time it is:

  • With audio, play.rs re-derives Clock { media: end - sink.buffered(), wall: now } on every audio frame, so the offset is whatever the speaker happens to hold, bounded by a hardcoded AUDIO_BUFFER_MAX of 1s.
  • Video-only, video_clock is anchored once on the first frame and never re-anchored (its sole write is gated on is_none()), so a first frame that arrives late appears to leave playback behind live for the rest of the session. js/watch/src/sync.ts handles exactly this by lowering its reference when a frame arrives earlier. The quest reproduces that before calling it a defect.

The plan extends moq_mux::pace::Pacer rather than adding a second primitive, because it is already most of Sync: anchor: Option<(Instant, u128)> is Sync's reference, it re-anchors forward only, and with_lead is nearly moq-watch's buffer. What it lacks is a delay offset.

It keeps the name Pacer. The interview had settled on renaming it to Clock, which turned out to be taken by the capture-side shared epoch (rs/moq-mux/src/clock.rs, used across moq-audio's capture path and moq-cli publish). Keeping the name also keeps the work additive, which is why this branches from main and sits in m0 beside the defect it fixes rather than in the dev line.

--delay replaces --max-age on play with no alias: nothing older than delay is worth presenting, so one number serves as both the playout offset and the subscription's max age. --max-age is unchanged on import, the stdout containers, and rtmp export.

Scoped out after the interview: the "auto" and "instant" modes of moq-watch's Delay, and any FFI surface — libmoq and moq-ffi hand decoded frames to a caller callback and have no playout stage, so there is nothing there to delay yet.

What the review changed

Codex found the plan wrong in five places, four of them load-bearing. All are folded in; the consolidated reply has the detail. In short: the name collision above; lead/hurry would have discarded the delay on the first frame and every re-anchor; nothing wakes a parked wait; a timed poll_wait has no deadline source because kio has no time module, so the sleep stays in the caller; and AUDIO_BUFFER_MAX gates writes rather than sizing the ring, which is private constants in rs/moq-audio/src/playback/sink.rs.

Three A/V questions are recorded as open rather than settled — anchor ownership when both streams exist, what the speaker position contributes under a delay, and whether writes need a per-chunk bound. They want the code in front of them, and the answers change what the tests assert.

Also in this PR

Two quests that #3396 made stale, fixed here since that PR is what made them so:

Test plan

  • cargo run -p quest -- check: 257 documents ok (links resolve, index matches the tree, headings valid, Required acyclic).
  • bun remark quest/ --frail clean.
  • No code changes.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T23:19:48.664450Z acdc608 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 776404c7b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m1/playout-clock.md Outdated
nearly `moq-watch`'s `buffer`. The only missing piece is a delay offset, so
extend the primitive rather than adding a parallel one beside it.

- Rename `Pacer` to `Clock` (`moq_mux::Clock`), since it now serves playout as

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Choose a non-conflicting name for the playout clock

The reviewed tree already exports an unrelated moq_mux::Clock from rs/moq-mux/src/clock.rs, and that capture clock is used throughout moq-audio, moq-video, and moq-cli. Renaming Pacer to the same root symbol therefore cannot be completed as the listed three-call-site rename: it either creates a duplicate re-export or requires renaming a second published API and all of its consumers. Reconcile the name and scope before this quest is executable. (Written by GPT-5.6 Sol)

AGENTS.md reference: quest/AGENTS.md:L92-L92

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
- Size the audio sink to `delay` rather than the hardcoded `AUDIO_BUFFER_MAX`,
mirroring `ringSamples(rate, delay)` in `js/watch/src/audio/latency.ts`. Keep a
floor the way `ringSamples` does: a zero-depth ring can never be read from.
- `--delay <duration>` replaces `--max-age` on `play`, with no alias. The two are

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the old play flag parsable during the rename

For existing moq play invocations, removing the old spelling with no alias produces only a generic unknown-argument failure. The reviewed tree currently calls that flag --latency-max, not --max-age, so the plan should identify the actual spelling and retain it as a hidden alias or parsable rejection that names --delay; otherwise users receive neither compatibility nor an actionable migration. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L140-L142

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +52 to +55
- `--delay <duration>` replaces `--max-age` on `play`, with no alias. The two are
one number here, since nothing older than `delay` is worth presenting, so
`delay` is what goes on the wire as the subscription's max age. `--max-age`
stays unchanged on `import`, the stdout containers, and `rtmp export`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add the CLI documentation migration to the quest

When this rename is implemented, doc/bin/cli.md will still describe play --latency-max and contains an example invocation using it. Because the plan explicitly removes the previous flag, those instructions will stop parsing unless the quest includes a repository-wide reconciliation of moq play examples and the primary CLI page. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L226-L226

Useful? React with 👍 / 👎.

#### Naming note

`Latency` is already doubly overloaded: the `Bound | {min,max}` type (`js/watch/src/sync.ts:16`) and `class Latency` (`js/hang/src/util/latency.ts:21`, jitter+buffer). Different packages, both exported. Worth resolving before adding a third meaning.
Mostly resolved: `js/watch`'s `Latency` / `Bound` types are gone, replaced by `Delay` plus a separate `buffer`, leaving `class Latency` (`js/hang/src/util/latency.ts`, jitter+buffer) as the only remaining use. Pick a name for the absolute mode that does not reintroduce the collision.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Declare the absent Delay refactor as a blocker

In the reviewed tree, js/watch/src/sync.ts still exports Bound and Latency, and SyncInput still has a latency getter; neither the quoted Delay type nor a separate buffer input exists. Rewriting this main-targeted quest around that absent API, while declaring the collision resolved and adding no dependency on its refactor, leaves the plan unusable if selected now. Keep the current model in the plan or add the refactor as an explicit prerequisite. (Written by GPT-5.6 Sol)

AGENTS.md reference: quest/AGENTS.md:L92-L92

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca7ce223a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m1/README.md Outdated
- [#3086](/quest/m1/3086-refactor-net-make-group-delivery-order-a-handle-and-move.md) - refactor(net): make group delivery order a handle, and move timestamp-based skipping into moq-net
- [#3161](/quest/m1/3161-retention-should-reclaim-idle-open-groups-now-that-expiry.md) - Retention should reclaim idle open groups now that expiry is timestamp-only
- [Group overflow](/quest/m1/group-overflow-abort.md) - an oversized open group aborts for every reader instead of shedding its head
- [Playout clock](/quest/m1/playout-clock.md) - moq play presents on a clock it controls, with a `--delay` offset and forward re-anchoring

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Move the main-targeted quest out of the dev milestone

This entry is added to m1, whose plan says every child quest branches from dev, while the new quest explicitly says to branch from main; the repository's branch rule also confirms that additive API work belongs on main. Selecting this quest from the ordered m1 list therefore gives contradictory execution instructions. Move it to the main-oriented feature milestone, or deliberately retarget the quest to dev. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L228-L230

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +41 to +43
- Add the offset: `send_at = anchor + (ts - base) + delay`, defaulting to zero
so the three existing call sites (`rs/moq-srt/src/server.rs` twice, and
`rs/moq-cli/src/subscribe.rs`) need no change at all.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the delay through Pacer re-anchoring

With the current Pacer, pace accepts an instant only when send_at - now <= lead, while lead defaults to zero and hurry returns now. Merely adding delay to send_at therefore makes the first delayed frame exceed the default lead and discards the offset, and every later re-anchor also returns an undelayed instant. Specify how the lead comparison excludes or incorporates the fixed delay and how hurry preserves it, rather than exposing two public knobs whose interaction defeats the promised behavior. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L168-L172

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +53 to +55
- Size the audio sink to `delay` rather than the hardcoded `AUDIO_BUFFER_MAX`,
mirroring `ringSamples(rate, delay)` in `js/watch/src/audio/latency.ts`. Keep a
floor the way `ringSamples` does: a zero-depth ring can never be read from.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Configure the actual audio ring instead of its throttle

AUDIO_BUFFER_MAX does not size the audio sink: play.rs only compares it with sink.buffered() to throttle writes, while the actual ring uses the private LATENCY = 50ms and CAPACITY = 3s constants in rs/moq-audio/src/playback/sink.rs. Replacing that high-water mark with delay leaves the real audio buffering unchanged, so audio cannot reliably honor the requested playout offset. Include a lower-layer sink configuration or another mechanism that controls when queued samples reach the speaker. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L150-L153

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Thanks — three of the four are addressed, one is a deliberate call.

Choose a non-conflicting name — agreed, fixed in ca7ce22. moq_mux::Clock is the shared capture epoch in rs/moq-mux/src/clock.rs, so the rename is dropped entirely rather than resolved: Pacer paces playout as readily as it paces an export. That also removes the only semver break, so the quest now branches from main, and with the delay defaulting to zero the three existing call sites need no change at all.

Keep the old play flag parsable — agreed, fixed in f294731. The plan now keeps the old spelling hidden and parsable so it can refuse with a message naming --delay, per the Deprecation section of CLAUDE.md, and keeps its environment variable so a deployment configured that way can't slip past the refusal onto the default.

Add the CLI documentation migration — agreed, fixed in the same commit. The quest now calls for reconciling doc/bin/cli.md (prose and example invocation) and the rs/moq-cli/src/moq.rs doc comment, by grepping for the flag rather than fixing the primary page alone.

Declare the absent Delay refactor as a blocker — declining, but the underlying observation is right. #3396 is what introduces Delay + buffer and renames the flag; it is open, targets dev, and this PR must land behind it. That constraint is now stated in the PR description and is the only reason this targets dev rather than main. A Required bullet would encode a dependency between two quest documents that both land in the same batch, and would need removing again as soon as #3396 merges. Worth noting that quest 2278 lives on both branches, so the window you describe only opens if the two PRs merge out of order.

(written by Claude Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f294731025

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +44 to +46
- Add the pair the rest of the crate uses: `poll_wait(&kio::Waiter, ts) ->
Poll<()>` with an `async fn wait(ts)` wrapping it, per the Async / poll
plumbing section of `rs/CLAUDE.md`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wake pending waits when the anchor moves

When audio and video share one Pacer, one task can already be pending in wait(ts) when an earlier-arriving frame on the other stream moves the anchor forward. The current Pacer only calculates an instant per call, while Sync.#setReference wakes every existing wait so it can recompute against the new reference. Without an equivalent notification or generation mechanism, the proposed wait either remains asleep until its stale deadline or holds exclusive state and prevents the re-anchor, defeating catch-up for already queued frames. Include that mechanism and a regression test where another stream shortens an active wait. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L152-L156

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +50 to +54
- Delete the local `Clock` struct and `video_clock`, and route both audio and
video through the one `moq_mux::Pacer`. The pacer's anchor becomes the single
authority; the speaker-derived clock goes away.
- Size the audio sink to `delay` rather than the hardcoded `AUDIO_BUFFER_MAX`,
mirroring `ringSamples(rate, delay)` in `js/watch/src/audio/latency.ts`. Keep a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid applying the delay twice to audio

If routing audio through the newly proposed Pacer::wait means waiting for line 41's delayed send_at before calling Sink::write, a sink sized to the same delay then holds those samples for roughly another delay before the speaker consumes them. Video waits only on the pacer, so with --delay=100ms audio can trail it by about another 100 ms. Specify that audio feeds the ring ahead of its deadline and only updates the shared anchor, or subtract the sink depth from the audio write deadline, and verify the resulting A/V offset. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L156-L156

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

All five new findings verified against the tree; four fixed in 222be14a, one declined again.

Preserve the delay through re-anchoring — agreed, and this was the real bug in the plan. pace re-anchors when at.saturating_duration_since(now) > self.lead, lead defaults to zero, so a send_at carrying the delay overshoots on the very first frame and hurry hands back an undelayed now. The plan now applies the offset after the lead check, hurry's now included, leaving lead to guard how far media may run ahead of wall-clock. delay = 0 is then bit-for-bit the current behavior, which is what leaves the export path alone.

Wake pending waits when the anchor moves — agreed. poll_wait now registers its waiter and the re-anchor wakes it to recompute, cited against Sync.#setReference for the same reason. Added to the test list as a wait that shortens when the other stream moves the anchor.

Configure the actual audio ring — agreed, and the plan was factually wrong. AUDIO_BUFFER_MAX sizes nothing; play.rs only compares it against sink.buffered() to throttle writes, while the ring is built from the private LATENCY (50ms) and CAPACITY (3s) in rs/moq-audio/src/playback/sink.rs. Honoring delay means making the depth a sink parameter.

Avoid applying the delay twice to audio — agreed, and it falls out of the above. The ring depth is audio's playout offset, so audio writes ahead of its deadline and only reports the anchor; video, with no buffer downstream, is what waits on the pacer. The A/V offset is now something the quest verifies rather than assumes.

Move the main-targeted quest out of the dev milestone — agreed, resolved the other way. quest/m1/README.md does say "Branch these quests from dev, not main", and my previous commit had put main in the quest, so you're right that the two contradicted. Rather than move milestones, the quest goes back to dev: the flag it replaces is renamed there by #3396, so dev is where the work has something to replace. The note now says the milestone picks the branch, not semver, since nothing here breaks a published item.

Declare the absent Delay refactor as a blocker — declining, unchanged from the previous round. #3396 is the refactor, it targets dev, and the merge-order constraint is stated in the PR description; auto-merge is deliberately off until it lands.

(written by Claude Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 222be14a60

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +73 to +76
- Do not delay audio twice. The ring depth *is* audio's playout offset, so audio
writes ahead of its deadline and only reports the anchor; if it also slept on
the pacer it would trail video by roughly another `delay`. Video, which has no
such buffer downstream, is what waits on the pacer. Verify the resulting A/V

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Anchor audio output to the shared playout clock

When audio and video first decode at different wall times, this flow never schedules audio against the shared Pacer: playback::Sink::write explicitly has no clock scheduling, and ring depth only controls queued PCM. For example, if audio timestamp 0 arrives 50 ms after video timestamp 0, that late timestamp does not re-anchor the pacer, while the speaker starts according to when its ring fills, so the streams retain the startup skew. Keep the speaker position in the clock calculation or make the lower-level sink timestamp/deadline aware, and cover staggered audio/video startup with a regression test. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L152-L153

Useful? React with 👍 / 👎.


## Related

- [Playout clock](/quest/m1/playout-clock.md) - ports the same anchoring model to Rust as `moq_mux::Clock`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refer to Pacer in the related quest

This new related entry says the Rust model uses moq_mux::Clock, but the linked quest explicitly keeps Pacer and identifies Clock as the unrelated capture epoch. Leaving the stale type name here reintroduces the exact collision the plan resolves and can send an executor toward the wrong primitive; describe the port as using moq_mux::Pacer. (Written by GPT-5.6 Sol)

AGENTS.md reference: quest/AGENTS.md:L43-L45

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Two new findings this round, both fixed in e97590cb. Three others were carried forward from threads already resolved in 222be14a.

Anchor audio output to the shared playout clock — agreed, and this is the better answer than what I wrote last round. Saying audio "writes ahead and only reports the anchor" left staggered startup unsolved for the reason you give: Sink::write has no clock, so a ring that fills 50ms late keeps that skew for the session. buffered() is documented as the pacing signal for exactly this ("the sample playing right now was written at roughly last_timestamp - buffered()"), so audio now feeds that instant to the pacer as a re-anchor observation. What the rework removes is the second clock, not the measurement. Staggered startup is in the test list.

While rewriting that I also corrected the ring bullet again: AUDIO_BUFFER_MAX is the write throttle that holds the depth, and holding it at delay is what makes the speaker play delay behind. CAPACITY (3s) is the separate ceiling that has to admit the requested delay, and LATENCY (50ms) is the device target, not the offset.

Refer to Pacer in the related quest — agreed, a straight miss on my part. 2278's Related bullet still said moq_mux::Clock, which is the collision the plan exists to avoid. It now reads "extending moq_mux::Pacer".

Carried forward but already resolved at 222be14a, so no action:

  • Move the main-targeted quest out of the dev milestone — the quest went back to dev in that commit; it no longer says main.
  • Wake pending waits when the anchor movespoll_wait registers its waiter and the re-anchor wakes it, with the shortened-wait regression test, added in that commit.
  • Declare the absent Delay refactor as a blocker — declined twice above; feat!: split moq-watch latency into delay + buffer, and rename --latency-max #3396 is the refactor and the merge-order constraint is in the PR description, with auto-merge deliberately off until it lands.

(written by Claude Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e97590cb3f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +50 to +52
- Add the pair the rest of the crate uses: `poll_wait(&kio::Waiter, ts) ->
Poll<()>` with an `async fn wait(ts)` wrapping it, per the Async / poll
plumbing section of `rs/CLAUDE.md`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Provide a deadline source to poll_wait

When the anchor remains stable, this API has nothing that can wake a pending poll at its deadline: kio::Waiter only receives notifications from registered waiter lists, while timed wakeups in this repository require a Timer/Deadline from moq-net (rs/moq-net/src/runtime.rs). The re-anchor notification described below covers state changes, not time passing, so a normal video wait can park forever. Pass a timer source into the pacer or keep the timed sleep in the runtime-owning caller. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +73 to +78
- Audio anchors the shared pacer from where the speaker actually is, not from
what was last written. `Sink::write` has no clock, and `buffered()` is
documented as the pacing signal for exactly this: the sample playing now was
written at roughly `last_timestamp - buffered()`. Feed that instant to the
pacer as a re-anchor observation, so the speaker keeps its authority over the
audio position while the pacer stays the single clock. What goes away is the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Anchor the delayed pacer at the live edge

With audio enabled and a nonzero delay, last_timestamp - buffered() identifies the sample sounding at wall-clock now, but the proposed pacer shifts every scheduled result by delay. Feeding that playing timestamp in as the anchor therefore schedules video with the same timestamp at now + delay, even though audio already played it at now. The newly added speaker-position instruction thus reintroduces a full-delay A/V skew; anchor at the corresponding live edge, such as playing_timestamp + delay, or provide an observation path that maps the speaker position directly without applying the offset again. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +65 to +68
- Hold the ring at depth `delay`, which is what makes the speaker play `delay`
behind the newest written sample. `AUDIO_BUFFER_MAX` is that throttle today,
hardcoded at 1s and compared against `sink.buffered()`; sizing it to `delay`
is the offset. It is not the ring's capacity, though: the ring is built from

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound each write to the requested audio depth

For decoded PCM frames longer than delay, changing the throttle alone does not hold the ring at that depth: play_audio currently splits arbitrary PCM frames into chunks of up to one second and checks sink.buffered() only before each whole Sink::write (rs/moq-cli/src/play.rs:385-425). With the default 100ms delay, an empty sink accepts the first one-second chunk, after which subsequent sleeps preserve roughly that oversized queue and audio trails video by far more than requested. Size each part to the remaining headroom or add a sink operation that blocks or caps the accepted duration, and cover a long PCM frame in verification. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Three new findings, all real, all fixed in ff16d0f2. The rest were carry-forwards of threads already resolved.

Anchor the delayed pacer at the live edge — agreed, and this was an arithmetic error I introduced last round. last_timestamp - buffered() is what the speaker is sounding now, and the pacer shifts every result by delay, so anchoring on it schedules video's copy of that timestamp at now + delay: a full-delay skew, which is the thing this quest exists to remove. The observation is the live edge that instant implies, last_timestamp - buffered() + delay. The plan now says so and says why.

Provide a deadline source to poll_wait — agreed. kio::Waiter carries state notifications; a timed wakeup needs Timer / Timers / Deadline from rs/moq-net/src/runtime.rs, so with a stable anchor the proposed poll_wait parks a video wait forever. The quest now makes this an explicit decision and recommends keeping the sleep in the runtime-owning caller, which already holds a timer, leaving the pacer a dumb primitive that returns a deadline and notifies on change. Threading Timers into moq-mux is named as the alternative, along with what it costs: the crate's runtime independence.

Bound each write to the requested audio depth — agreed. chunk is (sample_rate * stride).max(stride), exactly one second, and sink.buffered() is checked once per chunk, so at the default 100ms an empty sink swallows a whole second before the first check bites and the later sleeps just preserve that queue. Each part is now sized to delay - buffered(), or the sink grows a write that stops at a depth.

Carried forward but already addressed, so no action: Wake pending waits (in 222be14a, now folded into the deadline-source bullet above), Move the main-targeted quest (the quest went back to dev in 222be14a), and Declare the absent Delay refactor as a blocker (declined; #3396 is that refactor, the merge-order constraint is in the PR description, and auto-merge is off until it lands).

(written by Claude Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff16d0f286

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +68 to +70
- Delete the local `Clock` struct and `video_clock`, and route both audio and
video through the one `moq_mux::Pacer`. The two competing clocks collapse into
one anchor that both streams observe and steer by.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent video from re-anchoring past the speaker

When audio and video are enabled and video receives a tune-in burst, routing both streams through the current Pacer::pace lets video mutate the shared anchor: any frame mapped more than lead ahead invokes hurry, so video can jump to its newest timestamp while the speaker is still draining older PCM. Because later audio observations carry older timestamps and the pacer only re-anchors forward, audio cannot restore synchronization. Make the speaker the sole re-anchor source while audio exists, or explicitly discard audio to the same edge, and add a burst regression test. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L152-L156

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +45 to +47
the very first frame and `hurry` hands back an undelayed `now`. Keep `lead`
guarding how far *media* may run ahead of wall-clock, and shift every result
by `delay` after that check, `hurry`'s `now` included. At `delay = 0` the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject delays that cannot be represented as deadlines

For a video-only invocation with a large but parse-valid --delay, shifting the returned Instant by that duration can overflow and panic. The reviewed Pacer already uses checked_add and tests Duration::MAX specifically because CLI durations are unbounded, but this plan neither defines overflow behavior nor covers it. Bound and reject the delay during validation, or specify a checked fallback and add a large-delay regression test. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L152-L156

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Two new findings, both fixed in b98d81ff.

Prevent video from re-anchoring past the speaker — agreed, and the best catch of the review. Routing both streams through pace lets video mutate the shared anchor: a tune-in burst leads by more than lead, video hurries to its newest timestamp while the speaker is still draining older PCM, and because re-anchoring is forward-only audio can never pull it back. The fix is to keep the authority audio_clock.or(video_clock) already encodes in play.rs today, which my plan had quietly dropped: audio owns the anchor whenever it is present, video only reads it, and video-only is the case where video anchors.

Reject delays that cannot be represented as deadlines — agreed. Pacer uses checked_add and tests Duration::MAX precisely because CLI durations are unbounded, and shifting the returned instant by --delay reopens that. The plan now rejects an unrepresentable delay at parse time rather than saturating deep in the pacer, with the large-delay case in the test list.

Bound each write to the requested audio depth was carried forward but landed in ff16d0f2: each part is sized to delay - buffered(), or the sink grows a write that stops at a depth. Move the main-targeted quest landed in 222be14a (the quest branches from dev, not main). Declare the absent Delay refactor as a blocker stands declined, for the reasons in the two comments above.

(written by Claude Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b98d81ffa8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +81 to +84
the private `LATENCY` (50ms device target) and `CAPACITY` (3s ceiling) in
`rs/moq-audio/src/playback/sink.rs`, so `CAPACITY` has to admit the requested
`delay`. Plumb it, and keep a floor the way `ringSamples(rate, delay)` does in
`js/watch/src/audio/latency.ts`: a zero-depth ring can never be read from.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cap the ring depth before allocating it

For an audio invocation with a large but Instant-representable --delay, plumbing that duration into rs/moq-audio/src/playback/sink.rs::channel makes capacity_seconds allocate a ring proportional to the delay and sample rate. Values such as hours or days can therefore exhaust memory even though the proposed validation accepts them. Add a practical audio-capacity bound or use bounded storage rather than treating clock representability as the only limit. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L128-L129

Useful? React with 👍 / 👎.

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +120 to +124
- Bound the delay where it is parsed. `Pacer` already uses `checked_add` and
tests `Duration::MAX` because CLI durations are unbounded, so shifting a
returned `Instant` by an arbitrary `--delay` can overflow the platform clock.
Reject an unrepresentable delay up front rather than saturating deep in the
pacer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make EOF draining honor accepted delays

For any accepted --delay above four seconds on a sufficiently long audio stream, the plan holds roughly that much PCM in the sink, but play_audio still wraps its final drain in the fixed four-second AUDIO_DRAIN_MAX timeout. At EOF the timeout drops the remaining buffered tail and ends the media task before the requested playout completes. Either cap the accepted delay to the drain budget or redesign the drain watchdog so progressing playback may drain the configured depth. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L128-L129

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Two new findings, both fixed in 12d68c31, and they turned out to be the same finding as the overflow one from the last round.

Cap the ring depth before allocating it and Make EOF draining honor accepted delays — agreed on both. Together with the checked_add overflow, three separate things downstream assume the delay is small: shifting the paced Instant panics, capacity_seconds allocates the ring in proportion to delay times the sample rate, and AUDIO_DRAIN_MAX caps the EOF drain at a fixed 4s so any larger accepted delay silently truncates the tail. Rather than three guards, the plan now picks one ceiling and refuses past it, and derives the drain timeout from delay instead of the constant. Full drain of a delay-sized backlog is in the test list.

The other four comments are carry-forwards of threads already resolved: Bound each write in ff16d0f2, Prevent video from re-anchoring past the speaker and Reject delays that cannot be represented as deadlines in b98d81ff, and Move the main-targeted quest in 222be14a. Declare the absent Delay refactor as a blocker stands declined; #3396 is that refactor and the merge-order constraint is in the PR description.

This is where I'm stopping the review loop. Six rounds in, the last two have been the same "bound the delay" observation arriving from different call sites, which the single ceiling now covers. The PR is a quest document, not the implementation, and remaining detail is the implementer's to settle against the tree at the time.

(written by Claude Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12d68c31ac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m1/playout-clock.md Outdated
Comment on lines +88 to +89
first check bites and every later sleep preserves that oversized queue. Size
each part to `delay - buffered()`, or give the sink a write that stops at a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the audio floor when computing write headroom

When audio is enabled with --delay=0 (or any value below the sink's nonzero floor), the ring is correctly given a minimum depth above, but calculating each write from delay - buffered() leaves no headroom once buffered() reaches that floor. Following this instruction literally makes the writer wait forever or accept zero samples, so playback loses audio. Use the same effective floored depth for throttling and chunk sizing, or reject sub-floor audio delays, and cover this case with a regression test.

AGENTS.md reference: AGENTS.md:L152-L156

Useful? React with 👍 / 👎.

kixelated and others added 2 commits September 4, 2026 15:49
`moq play` has no playout offset anywhere in its path, and two clocks that
disagree. With audio the clock is re-derived per frame from `sink.buffered()`,
so the offset is whatever the speaker holds under a hardcoded 1s cap.
Video-only, `video_clock` is anchored once on the first frame and never
re-anchored, so a late first frame appears to leave playback behind live for
the session.

The plan extends `moq_mux::pace::Pacer` rather than adding a second primitive:
its `anchor` is already `js/watch/src/sync.ts`'s `reference`, it re-anchors
forward only, and `with_lead` is nearly moq-watch's `buffer`. It needs a delay
offset and the crate's usual `poll_*`/`async` pair, and a rename to
`moq_mux::Clock` now that it serves playout as well as export pacing.

Also de-stales two quests that PR #3396 moved under: 2981 said `latency_max`
where the field and flag are now `max_age`, and 2278 quoted the `Bound` /
`Latency` types it replaced with `Delay` plus a separate `buffer`, including a
naming note that break partly resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex found the plan wrong in four places, three of them load-bearing.

`moq_mux::Clock` is already taken by the capture-side shared epoch, used across
moq-audio's capture path and moq-cli publish. The rename cannot happen without
renaming that too, and its only justification was the clarity that name would
have bought, so `Pacer` keeps its name. That also keeps the work additive, which
drops the moq-srt and moq-cli call-site churn, moves the branch to main, and
moves the quest to m0 beside the defect it fixes.

Three hazards the plan had glossed:

- `pace` re-anchors when `send_at` leads `now` by more than `lead`, which
  defaults to zero, so a delay folded into `send_at` makes the first delayed
  frame overshoot and discard the offset. `hurry` returns `now` undelayed and
  drops it again on every later re-anchor.
- `Sync.#setReference` wakes every parked wait; `Pacer` has no notification at
  all, so a wait would sleep to a stale deadline when the other stream moves the
  anchor.
- Audio waiting for the delayed instant and then filling a sink that also holds
  a delay's worth trails video by roughly another delay.

And one factual correction: `AUDIO_BUFFER_MAX` does not size the sink, it gates
writes against `sink.buffered()`. The ring is sized by the private `LATENCY` and
`CAPACITY` constants in `rs/moq-audio/src/playback/sink.rs`, so honoring a delay
means configuring the sink rather than the throttle.

Also adds the `doc/bin/cli.md` reconciliation the flag rename needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the claude/quest-playout-clock branch from 12d68c3 to b271c7f Compare September 4, 2026 23:04

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b271c7f2b6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quest/m0/playout-clock.md

### Verification

- Unit tests on `Pacer` with `tokio::time::pause()`: a late first frame followed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Test the player integration rather than only Pacer

The listed unit tests do not protect the reported moq play regression: the parent tree's rs/moq-mux/src/pace.rs::re_anchors_to_live_edge already verifies that Pacer re-anchors, so these tests can pass even if the renderer remains wired to the one-shot video_clock or otherwise never uses the shared pacer. Add a deterministic play-level test showing that a late first video frame followed by the live edge changes the presentation deadline and fails before the wiring change; the manual check is not a regression gate. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L156-L156

Useful? React with 👍 / 👎.

`kio` has no time module, so a `poll_wait` that had to fire at a deadline would
park forever with nothing to arm it, and taking a `moq_net::Timers` the way
`origin::Driver::run` does would drag a runtime into a type that has never needed
one. `pace` keeps returning the instant and the caller keeps sleeping, which is
the contract the export path already relies on; the `poll_*` pair reports only
the anchor moving.

Adds an Open questions section for the A/V policy Codex kept circling: which
stream owns the anchor when both exist, what the speaker position contributes
given every result is shifted by the delay, and whether a per-write bound is
needed when play_audio can hand the sink a one-second chunk against a 100ms
delay. Those want the code in front of them, and the answers change what the
tests assert, so they belong in the quest rather than settled blind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Consolidated response to the Codex rounds, since several threads restate each other as the file changed.

Taken, in b271c7f and acdc608:

  • moq_mux::Clock collides. The important one, and my error — it is exported at lib.rs:40 from rs/moq-mux/src/clock.rs (the capture-side shared epoch) and used across moq-audio's capture path and moq-cli publish. I proposed that name without grepping for it. Pacer keeps its name; the rename's only justification was the clarity Clock would have bought. That also makes the work additive, dropping the moq-srt/moq-cli churn, moving the branch to main and the quest to m0.
  • lead and hurry would eat the delay. lead defaults to zero and hurry returns now, so folding the delay into send_at makes the first delayed frame overshoot and re-anchor, then drop it again on every later re-anchor. The plan now says to compare and re-anchor on the undelayed instant and apply the delay last, and to test that it survives both paths.
  • Nothing wakes a parked wait. Confirmed: zero Waiter/wake/notify in pace.rs, where Sync.#setReference resolves #update for exactly this.
  • poll_wait has no deadline source. Also correct, and it changed the shape: kio has no time module, so a timed poll_* would park forever. pace keeps returning the instant and the caller keeps sleeping — the contract the export path already relies on — and the poll_* pair reports only the anchor moving.
  • AUDIO_BUFFER_MAX does not size the sink. Right, it gates writes against sink.buffered(); the ring is the private LATENCY/CAPACITY constants in rs/moq-audio/src/playback/sink.rs. The plan said the wrong thing and now says configuring the sink.
  • Audio taking the delay twice. Both escapes are named in the plan now.

Recorded as open questions rather than settled: which stream owns the anchor when both exist, what the speaker position contributes when every result is shifted by the delay, and whether a per-write bound is needed given play_audio can hand the sink a one-second chunk against a 100ms delay. These are A/V policy that wants the code in front of it, and the answers change what the tests assert. quest/AGENTS.md has Plan hold "current decisions, open questions, or implementation guidance" — settling them blind here would over-fix a plan.

Stale rather than wrong: the --latency-max spelling and the Bound/Latency types in 2278 were both accurate against the tree at review time; #3396 has since merged and made the quest's text correct. On keeping the old play flag as an alias — that is a deliberate call, not an oversight: --delay and the max age are one number here, so there is nothing for a second spelling to mean.

Turned down: the m1-vs-main mismatch was reported against a quest that already said "Branch from dev". The conclusion landed anyway for a different reason — keeping Pacer removed the break, so it is main-targeted now and sits in m0.

(written by Claude Opus 5)

@kixelated
kixelated enabled auto-merge (squash) September 4, 2026 23:17
@kixelated
kixelated merged commit ef2997c into dev Sep 4, 2026
2 checks passed
@kixelated
kixelated deleted the claude/quest-playout-clock branch September 4, 2026 23:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: acdc608292

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

#### Naming note

`Latency` is already doubly overloaded: the `Bound | {min,max}` type (`js/watch/src/sync.ts:16`) and `class Latency` (`js/hang/src/util/latency.ts:21`, jitter+buffer). Different packages, both exported. Worth resolving before adding a third meaning.
Mostly resolved: `js/watch`'s `Latency` / `Bound` types are gone, replaced by `Delay` plus a separate `buffer`, leaving `class Latency` (`js/hang/src/util/latency.ts`, jitter+buffer) as the only remaining use. Pick a name for the absolute mode that does not reintroduce the collision.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove the nonexistent Latency collision

In the reviewed tree, js/hang/src/util/latency.ts does not exist, and a repository-wide search finds no class Latency; the remaining Latency declaration is an unrelated interface in the internal js/watch/src/audio/render.ts. This naming note therefore directs the quest executor toward a missing implementation and imposes a collision constraint that no longer exists. Update the plan to state that the old public collision is fully resolved, or identify the actual conflicting symbol. (Written by GPT-5.6 Sol)

AGENTS.md reference: quest/AGENTS.md:L43-L45

Useful? React with 👍 / 👎.

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.

1 participant