Skip to content

fix(audio): prime Opus decoders by advertised warmup - #5569

Merged
kixelated merged 6 commits into
mainfrom
quest/m1/audio-warmup
Oct 15, 2026
Merged

kixelated merged 6 commits into
mainfrom
quest/m1/audio-warmup

Conversation

@kixelated

@kixelated kixelated commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Joining or skipping within an Opus rendition can expose unconverged decoder output. Legacy browser playback dropped three callbacks regardless of their duration, losing audio even when no warmup was advertised.

Approach

Opus encoders and importers advertise the existing warmup field with the 80 ms pre-roll from RFC 7845 section 4.6. This covers moq_audio::Encoder::catalog(), fMP4 imports, and MKV imports without an OpusHead. MSF normalization derives the same codec property because MSF has no warmup field. Rust and browser consumers reach earlier for that history and trim decoded samples by timestamp after joining or losing data. The window is anchored at the first packet that decodes, so a refused packet cannot use it up. Ordinary group boundaries and declared ends keep output continuous. Rust trims after resampling so the resampler converges too.

Impact

  • Public Rust signatures are unchanged. Encoder::catalog() now reports warmup: 80ms for Opus. Start::Latest reaches earlier only for advertised warmup, and its effective skip budget still excludes warmup.
  • New public constants moq_mux::codec::opus::WARMUP (Rust) and Opus.WARMUP in @moq/hang/util (JS) name the 80 ms pre-roll once per language. A test checks that the audio Producer publishes exactly Encoder::catalog().
  • Browser playback uses advertised warmup instead of a fixed callback count. Without advertised warmup, the initial callbacks are kept.
  • Rust and browser MSF Opus normalization derives 80 ms of warmup. Other codecs leave warmup absent.
  • The existing catalog warmup field is now populated for Opus. No new wire field, framing, or negotiation change.

Alternatives

Callback counting cannot express a duration. Trimming before resampling left a transient in the first presented sample, so trimming happens at presentation.

Validation

Timestamp-controlled Rust and browser regressions cover joins, variable callback sizes, gaps, ordinary groups, declared returns, resampler convergence, and a refused first packet. MKV header/no-header, fMP4, and Rust/JS MSF regressions check the advertised warmup. In real Chromium, published Opus advertised warmup: 80 and played advancing decoded audio without browser errors. A benchmark sweeping decoder count against callback size covers the trimming path.

just check passes on the final head.

Follow-ups

None.

Completes /quest/m1/audio-warmup.md.

(Written by GPT-6, updated by Claude Opus 5.5)

kixelated and others added 2 commits October 15, 2026 07:10
Co-Authored-By: Codex <noreply@openai.com>
Advertise 80 ms of Opus warmup, including local MSF normalization, and
trim decoded samples by timestamp at joins and losses after resampling.
Preserve continuous output across ordinary groups and declared ends.

Co-Authored-By: Codex <noreply@openai.com>

Copy link
Copy Markdown
Collaborator Author

Quest outcome: implemented the agreed 80 ms Opus warmup policy, including Rust/JavaScript MSF normalization without a new wire field. Presentation trims by timestamp after joining or losing data; ordinary groups and declared ends remain continuous, and Rust primes the resampler before presenting samples.

just check completed with exit 0: 3,098 native, 464 capture, 186 playback and 3,431 JavaScript tests passed, alongside feature, WASM, lint/docs, quest and repository checks. Real Chromium publish/watch also passed. The branch merges cleanly with current main 3c0daa21b (the intervening commit only changes CI feature selection). No open audio API decisions remain. Leaving this PR as a draft for review.

Optional follow-up: audit the real-time TS fixture separately; its earlier late-drop failure and current pass are recorded without changing its timing or assertions here.

(Written by GPT-6)

@kixelated
kixelated marked this pull request as ready for review October 15, 2026 15:22
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 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-10-15T16:05:18.648668Z 41a72f8 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: e6d19aec8c

ℹ️ 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 rs/moq-audio/src/encode/producer.rs Outdated
Comment on lines +183 to +186
let mut config = encoder.catalog();
if matches!(options.settings.codec, super::Codec::Opus) {
config.warmup = Some(std::time::Duration::from_millis(80));
}

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 Set warmup in the encoder's catalog output

When callers use the public low-level encode::Encoder directly, Encoder::catalog() still returns an Opus config without warmup; only the higher-level Producer patches that config here. Such callers will continue publishing catalogs that make the updated consumers expose unconverged join output, even though catalog() promises to describe the encoder's output. Set the codec property in Encoder::catalog() so every consumer of that API receives it.

AGENTS.md reference: AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in 41a72f8: Encoder::catalog() now sets the 80 ms Opus warmup itself, so Producer just publishes it unchanged. Tests that feed a bare encoder catalog into a Consumer to check pre-skip, timestamps, or activity now clear warmup explicitly, so the warmup tests remain the only ones that cover trimming.

(Written by Claude Opus 5.5)

Comment thread rs/moq-mux/src/codec/opus/import.rs Outdated
Comment on lines +125 to +127
fn from(config: Config) -> Self {
let mut audio = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, config.channel_count);
audio.warmup = Some(std::time::Duration::from_millis(80));

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 Cover fMP4 Opus imports when advertising warmup

When Opus is imported from fMP4/CMAF, container/fmp4/import.rs:603 constructs AudioConfig::new(AudioCodec::Opus, ...) directly instead of using this conversion, so the resulting rendition still omits warmup. Viewers joining those imported tracks therefore do not request or trim the required pre-roll, leaving a major import path unaffected by this fix. Route that path through the shared Opus config conversion or set the same property there.

AGENTS.md reference: AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in 41a72f8: fMP4 Opus import now goes through the shared From<opus::Config> conversion, and opus_catalog_uses_the_decode_rate asserts the 80 ms warmup. TS, FLV, MKV, and raw imports already went through opus::config/From.

(Written by Claude Opus 5.5)

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c9f5ecf1-e8c2-453c-b254-47763676429e
📥 Commits

Reviewing files that changed from the base of the PR and between e6d19ae and 0c47124.

📒 Files selected for processing: 7
  • js/publish/src/audio/encoder.ts
  • quest/m1/README.md
  • rs/moq-audio/src/decode/consumer.rs
  • rs/moq-audio/src/encode/encoder.rs
  • rs/moq-audio/src/encode/producer.rs
  • rs/moq-mux/src/container/fmp4/import.rs
  • rs/moq-mux/src/container/fmp4/import_test.rs
💤 Files with no reviewable changes: 1
  • quest/m1/README.md

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 0 remain after this review.

Walkthrough

The change adds an 80 ms warmup value to Opus audio catalogs. JavaScript and Rust consumers use timestamps to trim warmup samples and re-arm trimming after gaps. The JavaScript callback-count warmup dropper is removed. Tests cover catalog values and consumer behavior. A benchmark recipe is added, and related quest documentation is removed.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 0c471

Playback that begins at the stream origin can lose its first 80 ms of audio. This is a bounded risk for merge, but preserving that audio requires a reliable way to identify the stream origin.

Pre-merge checks: Passed 5
Check name Status Explanation
Docstring Coverage Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 40 functions across 19 files.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely describes the main change: using advertised warmup to prime Opus decoders.
Description check Passed The description directly explains the Opus warmup problem, implementation approach, impact, and validation for the changeset.
✨ Finishing Touches
✨ Simplify code
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed commit: e6d19ae.

Duration-based warmup is a better fit than counting callbacks, and trimming after resampling is the right boundary. The ordinary-group/declared-return distinction keeps the change focused. One P2 is inline: rejected initial packets can consume the warmup window without priming the decoder.

I also verified the existing coverage findings: the public Encoder::catalog() and fMP4 Opus import still omit warmup. Referencing #5569 (comment) and #5569 (comment) instead of duplicating their inline comments. Setting the codec property at shared construction points is preferable to patching only the high-level producer.

Verification: static review of Rust/JS producers, import normalization, consumer trimming/recovery, and tests. No builds, tests, benchmarks, or browser playback executed. Browser tests exercise trimming directly; advertised-warmup integration with out-of-order cached history remains unverified.

if self.warmup_pending {
let warmup = moq_net::Timestamp::try_from(self.warmup)?.convert(timestamp.scale())?;
self.warmup_until = Some(timestamp.checked_add(warmup)?);
self.warmup_pending = false;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Start the warmup window only after a packet decodes successfully

This clears warmup_pending before decoder.decode() can return Error::Decode. At a join (or immediately after a loss), next_start is still None, so if initial malformed Opus packets are rejected, subsequent reads do not detect a discontinuity or re-arm warmup. For example, rejected packets at 0/20/40/60 ms followed by the first valid packet at 80 ms leave warmup_until at 80 ms and expose that decoder's first unconverged output immediately. Both moq-c/src/audio.rs and moq-cli/src/play/media.rs intentionally catch Decode and continue on this same consumer; consecutive container groups do not introduce a Gap just because the payload is invalid. Commit the warmup anchor only after successful decode (or re-arm it on decode failure), and add a regression that retries reads after rejected initial packets.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in 41a72f8: the warmup anchor is now committed only after decode() succeeds. The new warmup_starts_at_the_first_decoded_packet test refuses the first packet and checks that the output starts 80 ms after the first packet that decodes. Without the fix it fails, starting at 80 ms instead of 100 ms. The browser path doesn't need the same change, because a WebCodecs decode error is fatal and replaces the whole decoder.

(Written by Claude Opus 5.5)

Set warmup in Encoder::catalog() rather than only the Producer, route fMP4
Opus import through the shared Opus conversion, and anchor the warmup window
only after a packet decodes so refused packets cannot consume it.

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

Copy link
Copy Markdown
Collaborator Author

Merge summary for 41a72f83a:

  • Fixed all three review findings in one commit:
    • Encoder::catalog() now advertises the 80 ms Opus warmup itself instead of relying on Producer to add it.
    • fMP4 Opus import goes through the shared Opus conversion.
    • The warmup window is anchored only after a packet decodes. This has a regression test that fails without the fix.
  • Tests that feed a bare encoder catalog to a Consumer to check pre-skip, timestamps, or activity now clear warmup, so trimming stays covered by the dedicated warmup tests.
  • just check passes locally, CI is green including TS validation, and Codex reviewed the final head with no findings. CodeRabbit had no actionable comments on the earlier head.
  • No wire or public signature changes beyond the populated warmup field described in the PR body.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 15, 2026
@kixelated

Copy link
Copy Markdown
Collaborator Author

Follow-up planned in #5584: quest/m1/audio-warmup.md now also asks for one named 80 ms Opus pre-roll constant per language (encoder catalog, shared Opus conversion, MSF consumer, and the JS publish/MSF sites), plus a test that the audio catalog Producer publishes exactly Encoder::catalog(). Please land it here before merging rather than as a separate PR.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 15, 2026
kixelated and others added 2 commits October 15, 2026 12:43
Add `moq_mux::codec::opus::WARMUP` and `Util.Opus.WARMUP`, used by the
encoder catalog, the shared Opus conversion, and both MSF consumers. Test
that the audio Producer publishes exactly `Encoder::catalog()`.

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

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed commit: 774f96e.

The findings from my previous review are fixed: warmup anchors only after successful decoding, Encoder::catalog() advertises it directly, and fMP4 imports use the shared Opus conversion. Their unresolved thread status does not reflect the current code.

The new constants preserve 80 ms across Rust and JS with consistent units. The full Producer/Encoder catalog equality regression for Opus and PCM is a focused safeguard against the earlier drift. Centralizing the value is a useful simplification; no additional actionable bug found in this update.

Verification limits: static source and regression-test review through GitHub, including the main-merge interactions. No tests, builds, codec execution or browser playback run. The new parity test does not establish AAC or wire-serialization behavior.

@kixelated
kixelated added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 03355da Oct 15, 2026
16 checks passed
@kixelated
kixelated deleted the quest/m1/audio-warmup branch October 15, 2026 20:32
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