Skip to content

feat(audio): an encode backend seam and an AAC output codec - #4183

Merged
kixelated merged 3 commits into
quest/m1/audio-codecs/READMEfrom
quest/m1/audio-codecs/encode-backend
Sep 25, 2026
Merged

kixelated merged 3 commits into
quest/m1/audio-codecs/READMEfrom
quest/m1/audio-codecs/encode-backend

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Problem

moq_audio::encode::Encoder matched on its codec inline, so a platform AAC encoder (AudioToolbox, Media Foundation, MediaCodec) had nowhere to plug in, and a native publisher could only produce Opus or PCM.

Approach

  • encode::backend mirrors the decode seam. A crate-private Backend trait (encode, reset, set_bitrate, bitrate, delay, name) and an open that walks PLATFORM then SOFTWARE. It skips any candidate that doesn't emit the codec, and refuses when none opens. Refusals are reported the same way decode reports them. PLATFORM is empty for now.
  • libopus and PCM moved behind the trait without behavior changes. Codec rules (Opus rates and layouts, PCM's fixed bitrate, AAC's layouts and frame) stay in the front end, so every backend of a codec refuses the same settings.
  • Codec::Aac is AAC-LC (mp4a.40.2) at the input's rate and layout:
    • It takes the layouts that have a channelConfiguration: mono, stereo, 3.0, 4.0, 5.0, 5.1, and 7.1. 2.1, quad, 6.1, and discrete are refused, because synthesizing from a bare count would mislabel them.
    • The catalog is AAC { profile: 2 } with Container::Legacy. Its ASC is built from the settings with moq_mux::codec::aac::Config::encode at construction, so the rendition registers before any packet exists.
    • The ASC has no field for encoder delay, so the producer stamps each AAC packet delay frames earlier. The first input sample then decodes at the epoch. Opus is unchanged because its pre-skip already signals the delay.
    • Frames are 1024 samples. Settings::from_input sets frame_duration to match, and a duration that doesn't round to 1024 samples is refused.
    • The encoder refuses DTX.
  • set_bitrate goes through the backend. A backend that can't retune returns Unsupported and keeps its opening rate. The error docs say so, as in video.
  • Tests:
    • Selection order with stub candidates.
    • Codec::Aac refused under Auto on this host.
    • AAC catalog, layouts, frame duration, drain, and timestamp folding, through a test-only stub backend that can only be picked by name (like video's probe).
    • The existing Opus and PCM tests pass unchanged. The two tests that poked libopus ctls moved into the libopus backend.
  • FFI:
    • MoqAudioCodec.aac() is new.
    • frame_duration_us = 0 now means the codec's own frame, which AAC needs. libmoq already treated 0 that way.
    • libmoq's "aac" codec string parses through FromStr.
    • The Go wrapper gains AacAudioCodec(). Docs for py, swift, kt, go, and C follow.
  • Quest: deletes encode-backend.md and its references. The AudioToolbox, MediaCodec, and OBS audio quests now carry what this PR left for them.

Deviations from the quest plan

  • No flush in the trait. The contract is one packet per frame, since Encoder::encode publicly returns one Encoded and the producer stamps packets by counting frames. A pipelined codec (MediaCodec, and possibly AudioToolbox) needs flush plus a zero-or-more return. That changes Encoder::encode to return a Vec like video's, which is a semver break and belongs on dev with the first backend that needs it. Adding an always-empty flush now would be dead code, the same call feat(audio): a decode backend seam that prefers the platform codec #4131 made for decode. The AudioToolbox and MediaCodec quests note this.
  • Timestamp clamp. Priming that would land before a zero epoch is stamped at zero. Only the spacing of those warm-up packets is lost. Shifting the whole stream later instead would have put audio up to 44 ms behind video.

Impact

  • Public API (moq-audio), all additive: encode::Codec::Aac (appended), encode::Kind (new, mirrors decode::Kind), encode::Settings::kind (new field on a #[non_exhaustive] struct), and encode::Encoder::name(). "aac".parse::<Codec>() now succeeds instead of erroring. Settings::from_input(Codec::Aac, ..) fills in AAC's frame duration.
  • moq-ffi: MoqAudioCodec::aac() is a new constructor. MoqAudioEncoderOutput.frame_duration_us = 0 is now accepted as "codec default". It used to be refused, since 0 is no Opus duration, and the uniffi default stays 20000. The generated bindings pick up aac(), and the Go wrapper gains AacAudioCodec().
  • libmoq: no ABI change. "aac" is now an accepted codec string, and the header doc lists "opus", "pcm", "aac".
  • Wire and catalog: no format change. An AAC rendition uses the existing mp4a.40.2 + ASC catalog shape the gateways already publish.

Verification

  • just check passes on Linux.
  • There is no AAC encoder on this Linux host, so the refusal path is the only real-host AAC path exercised. Everything else AAC runs through the stub backend. No platform code was added, so nothing is left uncompiled.

Follow-ups

  • AudioToolbox encode is the first real PLATFORM candidate.
  • Consider flipping the uniffi frame_duration_us default to 0 on dev, so AudioEncoderOutput(codec: aac()) works without the explicit 0.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

@kixelated

Copy link
Copy Markdown
Collaborator Author

Maintainer decisions:

  • flush and a zero-or-more encode are deferred to dev, to land with the first backend that holds output back (already noted in the AudioToolbox and MediaCodec quests).
  • The uniffi frame_duration_us default moves to dev as well; until then AAC callers pass 0 explicitly.
  • AAC priming packets clamp to t=0.
  • encode::Kind is accepted as public API.

(Written by Claude Opus 5.5)

@kixelated
kixelated marked this pull request as ready for review September 25, 2026 19:52
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 25, 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 ⚠️ Failed 2026-09-25T23:26:30.589280Z e3360c8 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: acc4d1b4ed

ℹ️ 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 go/wrapper/types.go
Comment on lines +187 to +190
// AacAudioCodec selects AAC-LC through the platform's encoder for EncodeAudio.
// A host without one refuses it. Leave FrameDurationUs at 0 for AAC's own frame.
func AacAudioCodec() *AudioCodec {
return ffi.MoqAudioCodecAac()

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 Update the Go guide for the new AAC constructor

The new public AacAudioCodec constructor is exposed to Go, but doc/lib/go/index.md still gives no EncodeAudio codec-selection guidance or AAC frame-duration requirement. This leaves Go users without the required FrameDurationUs: 0 usage in the language guide, despite the repository's explicit requirement that an rs/moq-ffi surface change update doc/lib/go.

AGENTS.md reference: AGENTS.md:L94-L96

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.

Added the EncodeAudio note to doc/lib/go: OpusAudioCodec or AacAudioCodec, and FrameDurationUs 0 for AAC's own frame.

(Written by Grok 4.7)

Comment on lines +391 to +394
let frames = i128::from(frames) - delay as i128;
let offset_us = (frames * 1_000_000).div_euclid(i128::from(codec_rate));
let micros = (i128::from(epoch_us) + offset_us).max(0);
let micros = u64::try_from(micros).map_err(|_| moq_net::TimeOverflow)?;

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 AAC packet spacing near timestamp zero

When an AAC epoch starts earlier than the encoder delay, as with the common timestamp_us = 0, clamping each adjusted timestamp independently maps several consecutive packets to PTS 0. The added test itself expects [0, 0, 0, 20000]; decode::Consumer::read treats those overlaps as discontinuities and emits multiple decoded frames at the same timestamp, corrupting the audio timeline. Preserve packet spacing by shifting the whole epoch consistently, or reject an epoch that cannot represent the folded delay rather than saturating every packet.

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.

Leaving the clamp. Priming that would land before a zero epoch stays stamped at t=0; shifting the whole epoch later would put audio up to about 44 ms behind video. That call is already recorded on this PR.

(Written by Grok 4.7)

Comment thread rs/moq-ffi/src/audio.rs
Comment on lines 107 to +109
/// Encoded frame duration in microseconds. Opus accepts exactly
/// 2500/5000/10000/20000/40000/60000 us, and the default 20 ms matches the
/// JS publish path.
/// JS publish path. 0 takes the codec's own frame, which AAC needs.

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 the AAC binding default select its codec frame

In UniFFI languages, constructing AudioEncoderOutput(codec=AudioCodec.aac()) omits frame_duration_us and therefore receives the declared 20,000 µs default, not 0. encode_audio then overwrites AAC's computed 1024-sample duration with 20 ms, so a normal 48 kHz AAC request is rejected as a 960-sample frame before any platform backend opens. Use 0 as the binding default, which still resolves to 20 ms for Opus, or otherwise make the codec-specific default automatic.

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.

Leaving the uniffi default at 20000. AAC callers pass frame_duration_us = 0 until that default moves to 0 on dev. Opus stays 20 ms either way.

(Written by Grok 4.7)

Comment thread rs/moq-ffi/src/audio.rs
Comment on lines +303 to +305
if output.frame_duration_us != 0 {
options.settings.frame_duration = Duration::from_micros(output.frame_duration_us.into());
}

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 Recompute AAC framing after an output-rate override

When an FFI caller requests AAC with frame_duration_us = 0 and an output sample rate different from the input, Settings::from_input computes the 1024-sample duration using the input rate, then sample_rate is replaced, and this branch preserves the stale duration. For example, 44.1 kHz input with 48 kHz output retains about 23.22 ms, which validates as roughly 1115 samples and is refused instead of being resampled and encoded. Derive the codec-default duration after applying the output-rate override.

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. frame_duration_us 0 now recomputes AAC's 1024-sample frame after an output-rate override. A 44.1 kHz input with a 48 kHz output is refused for a missing encoder, not a stale duration.

(Written by Grok 4.7)

kixelated and others added 3 commits September 25, 2026 16:04
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq_audio::encode now opens a backend per codec the way decode does:
platform encoders first, then software, selected by Settings::kind.
libopus and PCM move behind it unchanged. Codec::Aac is AAC-LC at the
input's rate and layout, with its AudioSpecificConfig synthesized at
construction and the encoder delay folded into packet timestamps. No
platform AAC encoder is wired in yet, so it is refused at construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
from_input computed 1024 samples at the input rate, then an output
sample-rate override left that duration in place. Also document
AacAudioCodec and FrameDurationUs 0 in the Go guide.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
@kixelated
kixelated force-pushed the quest/m1/audio-codecs/encode-backend branch from acc4d1b to e3360c8 Compare September 25, 2026 23:25
@kixelated

Copy link
Copy Markdown
Collaborator Author

Landing the encode backend seam and an AAC output codec on this unpublished quest branch. Rebased onto quest/m1/audio-codecs/README after #4178, keeping the TS-export PCE completion. Public API is additive: encode::Codec::Aac, encode::Kind, Settings::kind, Encoder::name(), and MoqAudioCodec.aac() (AacAudioCodec in Go), with frame_duration_us 0 selecting the codec's own frame. That default AAC frame is now sized at the output sample rate, and the Go guide notes it.

(Written by Grok 4.7)

@kixelated
kixelated merged commit c6ec067 into quest/m1/audio-codecs/README Sep 25, 2026
8 checks passed
@kixelated
kixelated deleted the quest/m1/audio-codecs/encode-backend branch September 25, 2026 23:25
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