Skip to content

fix(ffi)!: carry Opus frame durations in microseconds - #3410

Open
kixelated wants to merge 2 commits into
devfrom
quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings
Open

fix(ffi)!: carry Opus frame durations in microseconds#3410
kixelated wants to merge 2 commits into
devfrom
quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings

Conversation

@kixelated

@kixelated kixelated commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Opus codes a 2.5 ms frame, moq-audio has always accepted it, and its encoder tests cover it. Every public binding advertised the same support but could not carry it:

  • Root cause (FFI/C): the scalar was an integer number of milliseconds (u32 in MoqAudioEncoderOutput, uint32_t in moq_audio_encoder_output), converted with Duration::from_millis. 2.5 is unrepresentable, so a caller either passed 2 or 3, and libopus rejects both.
  • Root cause (JS): OpusConfig.frameDuration was already floating point, but the encoder stored it in the catalog's jitter field and read it back from there. jitter is u53, so Catalog.u53(2.5) threw and 2.5 ms could not be published at all. The demo's own "2.5 ms" option was therefore dead.

The fix changes the unit rather than the width: both FFI records now spell the frame duration in microseconds.

  • moq-ffi: frame_duration_us is a u32 with #[uniffi(default = 20000)], so the field can now be omitted from the generated Python/Swift/Kotlin/Go/Dart constructors. That also lands the audio bullet from Add UniFFI defaults to caller-constructed configuration records #3189; the rest of that audit stays with its quest.
  • libmoq: frame_duration_us is a uint32_t, and 0 selects the 20 ms default (the same "0 means unset" convention the sibling fields use).
  • js/publish: the resolved encode settings are now a Resolved value holding the catalog config plus the exact frameDuration. The framer and the WebCodecs config read the exact value; the catalog's jitter carries Math.ceil of it, because jitter is an integer upper bound on how long a decoder waits, not the encoder's cadence. A 2.5 ms Opus track therefore publishes jitter: 3 and encodes at 2500 µs.

No new moq-audio API: encode::Producer::new already validates frame_duration against the codec's table via Encoder::new, so each FFI surface just hands it a Duration::from_micros and the existing error surfaces unchanged.

Why microseconds, and not fractional milliseconds or an enum

An earlier revision of this PR made the scalar an f64 millisecond value. Microseconds are better on three counts:

  • Exact. 2.5005 ms rounds to 2,500,500 ns, which as_micros() truncates back to the supported 2500, so a float field silently accepts values the doc promises it rejects (this was Codex's P2 on the previous revision). An integer microsecond field makes that unrepresentable rather than needing a remainder check, and it deletes the float hygiene (is_finite, <= 0.0, .round(), saturating-cast) the conversion needed.
  • Consistent. Every other duration in both ABIs is an integer with a unit suffix, and _us is already the fine-grained one: timestamp_us, rtt_us. moq_track_info even documents its timescale as "matching the timestamp_us units used everywhere else in this ABI". A double would have been the only float in either record.
  • Codec-generic. An {Ms2_5, Ms5, Ms10, Ms20, Ms40, Ms60} enum was considered and rejected: moq_encode_audio takes the codec as a string parsed by Codec::from_str, so "pcm" is reachable through the same struct and PCM takes any duration containing a whole number of samples. The enum would either forbid those or need a Custom(scalar) escape hatch, putting the scalar back plus a second spelling to validate. moq_audio::encode::Codec is #[non_exhaustive] because more codecs are coming, and AAC-LC's frame is 1024 samples at the sample rate, which is not a round millisecond count at all.

@moq/publish's frameDuration keeps Time.Milli: it already lives on OpusConfig (so it is codec-scoped), JS has no integer type for the exactness argument to buy anything, and the JS bug was the jitter conflation, not the unit.

Unrelated: a pre-existing -D warnings break

rs/moq-video's DmaBufExport::inner is dead in libmoq's feature selection, because vaapi pulls in dmabuf without render, and -D warnings turns that into a hard error. This is pre-existing on dev and unrelated to the change here (cargo clippy -p moq-video --no-default-features --features nvidia,vaapi -- -D warnings fails on an untouched tree), but it makes just check fail for any PR touching libmoq, so the field is gated on render here. into_parts is already gated on dev; only the field was left.

It is Linux-only (#[cfg(all(target_os = "linux", feature = "dmabuf"))]), so no macOS just check sees it. CI is the gate.

Public API changes

Breaking, which is why this targets dev:

  • moq_ffi::MoqAudioEncoderOutput::frame_duration_ms: u32 -> frame_duration_us: u32, now with a UniFFI default of 20000. Every wrapper (py, swift, kt, go, dart) re-exports the generated record, so their callers see the same change.
  • libmoq's moq_audio_encoder_output.frame_duration_ms -> frame_duration_us, with 0 now meaning "the 20 ms default" rather than "0 ms".

The field is renamed, not just reinterpreted, so a caller that spelled it by name gets a compile error. Anywhere a positional/zero-value initializer slips an old millisecond number through, no supported Opus duration in milliseconds (5, 10, 20, 40, 60) is also a supported one in microseconds, so it fails loudly at Encoder::new rather than encoding at the wrong cadence. Struct size and field offsets are unchanged.

Additive:

  • @moq/publish's Audio.resolve and Audio.Resolved (new exports). Audio.OpusConfig.frameDuration keeps its Time.Milli type and meaning.

No wire format change: the catalog jitter field is unchanged in type and meaning, so no drafts/ update applies.

Cross-Package Sync

Walked the rs/moq-ffi row:

  • rs/libmoq: done (above).
  • {py,swift,kt,dart}/, go/wrapper/moq/*.go: nothing to hand-write. All five expose MoqAudioEncoderOutput as a generated type or a type alias (swift/Sources/Moq/Aliases.swift, kt/.../Aliases.kt, go/wrapper/types.go, py/moq-rs/moq/types.py); none re-declares the field, so the regenerated bindings carry the new name, type, and default on their own. dart/moq has no audio wrapper at all.
  • doc/lib/{py,swift,kt,go,dart,c}: no change needed. Their raw-media examples all construct VideoEncoderOutput / call encodeAudio without spelling the encoder-output record, so no doc shows the field. Confirmed by grep across doc/**/*.md.
  • cpp/obs: does not use moq_encode_audio, so the moq.h change does not reach it.
  • demo/web: already offered a "2.5 ms" option that threw; it now works, with no code change on that side.

Test plan

  • just fix, just check, just test: all pass (Rust, JS, 53 Python tests).
  • New regression tests, each failing before the change:
    • moq-ffi: raw_audio_frame_durations publishes a 2.5 ms track and writes a frame through it, and asserts 2 ms is refused; default_frame_duration_matches_moq_audio pins the #[uniffi(default)] literal against encode::Options::default().
    • libmoq: audio_raw_publish_frame_durations covers 2.5 ms end to end through the C entry point, 0 selecting the default, and 2 ms being refused.
    • @moq/publish: audio/encoder.test.ts covers the default, all six Opus durations, the 2.5 ms jitter ceiling, AAC having no duration, and the rejection of 2.5005 / 15 / 0 / -20.
    • py: test_optional_binding_records_use_none_defaults now constructs AudioEncoderOutput without the field and checks the 20000 default plus a 2500 round-trip.
  • Not run: just rs macos / just rs windows (no such host), and the browser check js/CLAUDE.md asks for on publish changes. The JS half is exercised by the new unit tests over the pure resolve function rather than in a live browser.

Closes #3208.

Completes and deletes quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings.md.

🤖 Generated with Claude Code

(Written by claude-opus-5)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 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-05T08:15:38.217238Z db2c693 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: 0c5dd77335

ℹ️ 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/opus.rs Outdated
Comment on lines +42 to +43
let micros = duration.as_micros();
if !FRAME_DURATIONS.contains(&micros) {
return Err(Error::Unsupported(format!(
"opus frame duration must be 2.5/5/10/20/40/60 ms (got {micros} us)"
)));
if FRAME_DURATIONS.contains(&micros) {

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 sub-microsecond offsets from valid Opus durations

When an FFI caller supplies a value slightly above a supported duration, such as 2.5005 ms, conversion produces 2,500,500 ns but as_micros() truncates it to 2,500 µs, so this accepts a duration that the public API promises must be exactly one of the six supported values. The encoder then silently uses the truncated 120-sample frame size while retaining the nonstandard Duration; compare the full duration against the supported values or explicitly reject a sub-microsecond remainder.

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 on the diagnosis, and the fix went further than the check you suggested: the scalar is now an integer number of microseconds on both FFI surfaces (frame_duration_us), so a sub-microsecond remainder is unrepresentable rather than rejected.

That also removed the code this comment was anchored to. Codec::frame_duration_from_millis and the opus::validate_frame_duration split are gone; each FFI entry point hands encode::Options a Duration::from_micros(...) and Encoder::new validates it against the one existing table, as it already did for every native caller.

Rationale for microseconds over a tightened f64 is in the PR description: it matches the timestamp_us unit the rest of both ABIs uses, it keeps the field codec-generic (the C surface parses the codec from a string, so PCM and its arbitrary durations come through the same struct), and it deletes the float hygiene the conversion needed.

(Written by claude-opus-5)

@kixelated
kixelated force-pushed the quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings branch from 0c5dd77 to 73a91b7 Compare September 5, 2026 02:22
@kixelated

Copy link
Copy Markdown
Collaborator Author

Rebased onto dev (was 30 behind, conflicting).

One conflict, in rs/moq-video/src/frame.rs. dev has since landed its own fix for the DmaBufExport dead-code error that this PR was disclosing, so the version here is now byte-identical to dev's and this PR no longer carries that change. The "disclosed extra" note in the description above is stale; disregard it.

This branch is blocked on #3423 and will be red until it merges. dev's fix is itself broken: it lint-suppressed into_parts rather than compiling it out, so the function still reads inner, which keeps the field live and makes the field's own expect(dead_code) unfulfilled. -D warnings promotes that to an error, so moq-video fails to compile for anything selecting dmabuf without render, which includes moq-ffi and libmoq. Reproduced on pristine origin/dev at 419de6254:

error: this lint expectation is unfulfilled
   --> rs/moq-video/src/frame.rs:153:45

I deliberately did not fix it here. Two PRs independently fixing the same defect is what produced the conflicting third variant now on dev, so it lives in #3423 alone.

quest check passes (246 documents ok). No content changes beyond the conflict resolution.

(written by claude-opus-5[1m])

@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: 73a91b7d48

ℹ️ 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".

};
}

const frameDuration = codec.frameDuration ?? OPUS_FRAME_DURATION;

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 unsupported fractional Opus durations

When a caller supplies an unsupported fractional value such as Time.Milli(2.5005), resolve now accepts it because the catalog validates only Math.ceil(frameDuration) as the integer 3, while the exact 2500.5 µs value is forwarded to WebCodecs. Since WebCodecs accepts only 2.5/5/10/20/40/60 ms, encoder.configure then throws after the rendition has already been advertised and requested. This is independent of the prior Rust finding because validating moq-audio does not protect this browser-only path; narrow the public duration type to the supported set or validate it before constructing the catalog.

AGENTS.md reference: AGENTS.md:L176-L180

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.

Real, and fixed in 0d03b24. resolve now checks the duration against the supported set before it builds the catalog:

const OPUS_FRAME_DURATIONS = [2.5, 5, 10, 20, 40, 60];
...
if (!OPUS_FRAME_DURATIONS.includes(frameDuration)) {
    throw new Error(`opus frame duration must be ${OPUS_FRAME_DURATIONS.join("/")} ms: ${frameDuration}`);
}

Worth noting the late failure was not new to this PR: an unsupported integer like Time.Milli(15) passed Catalog.u53(15) fine and threw at AudioEncoder.configure on dev too. What this PR changed was extending that window to fractional values, since Catalog.u53 used to reject those early as a side effect of the bug being fixed here. The check closes both.

I did not narrow the type as you suggested. frameDuration is Time.Milli, a branded number, so a literal union does not compose with the Time unit constructors, and the runtime check is what mirrors the Rust path anyway. Covered by a new case in audio/encoder.test.ts over 2.5005, 15, 0, and -20.

(Written by claude-opus-5)

@kixelated
kixelated force-pushed the quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings branch from 73a91b7 to a92969b Compare September 5, 2026 05:39
@kixelated kixelated changed the title fix(ffi)!: carry Opus frame durations as fractional milliseconds fix(ffi)!: carry Opus frame durations in microseconds Sep 5, 2026
@kixelated
kixelated force-pushed the quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings branch from a92969b to 0d03b24 Compare September 5, 2026 07:37

@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: 0d03b24286

ℹ️ 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".

* Resolve a {@link Codec} against the captured PCM format, giving what the encoder will run with
* and the catalog rendition published alongside it.
*/
export function resolve(captured: Format, selected: Codec): Resolved {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep encoder resolution internal

audio/index.ts re-exports this module wholesale, so this publishes Audio.resolve and Audio.Resolved to every @moq/publish consumer even though repository production code uses the helper only inside Encoder. This turns an implementation detail combining catalog representation with WebCodecs framing state into a permanent public contract, making future encoder changes consumer-facing; keep the helper and type internal rather than widening the package surface. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L168-L170

Useful? React with 👍 / 👎.

@kixelated
kixelated force-pushed the quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings branch from 0d03b24 to cd3944d Compare September 5, 2026 08:01
…-bindings

Claim UUID: c237ce98-21b3-4b46-82fd-7769223b436c

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DLtoPv9B3779kGxLFTUGHR
@kixelated
kixelated force-pushed the quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings branch from cd3944d to 0732bc2 Compare September 5, 2026 08:02
Opus codes a 2.5 ms frame and moq-audio has always accepted it, but every
binding surface spelled the duration as an integer number of milliseconds, so
2.5 truncated to 2 and libopus refused it. JavaScript accepted a floating-point
value and then routed it through the catalog's whole-millisecond `jitter` field,
which rejected it outright.

Microseconds rather than fractional milliseconds: it is exact, it matches the
`timestamp_us` unit the rest of both ABIs already uses, and it keeps the field
codec-generic, which an enum of libopus' six durations could not (the C surface
takes the codec as a string, so PCM and its arbitrary durations are reachable
through the same struct).

- moq-ffi: `MoqAudioEncoderOutput::frame_duration_us` is `u32`, defaulting to
  20000 so the field can be omitted from the generated constructors.
- libmoq: `moq_audio_encoder_output.frame_duration_us` is a `uint32_t`, with 0
  selecting the 20 ms default.
- js/publish: the exact frame duration is kept beside the catalog config rather
  than inside it, and the catalog's `jitter` hint is the ceiling of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings branch from 0732bc2 to db2c693 Compare September 5, 2026 08:11

@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: db2c6935ee

ℹ️ 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 on lines +16 to +17
// The catalog jitter is a whole-millisecond hint, so it used to be the only place the frame
// duration lived and 2.5ms could not be published 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.

P1 Badge Describe the current jitter invariant

This comment records the pre-fix implementation with “used to be,” so it will become stale independently of the behavior under test. Rewrite it as the current invariant, such as the exact duration being retained separately from the integer jitter hint, and leave the migration history in the PR description.

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

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