From 0195d26bf63c7b6487203377d305b404c89a3a0a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 19:37:19 -0700 Subject: [PATCH 1/4] quest: claim aac-pce From 696adeb995d85036880a634192b3f18bdf181280 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 20:42:21 -0700 Subject: [PATCH 2/4] fix(mux): parse the AAC program config element instead of guessing stereo A channelConfiguration of 0 now takes its count from the PCE, in the AudioSpecificConfig or leading the first ADTS raw data block; reserved configurations are refused and 11 to 14 map to their real counts. Co-Authored-By: Claude Opus 5.5 --- quest/m1/audio-codecs/README.md | 5 +- quest/m1/audio-codecs/aac-pce.md | 34 -- quest/m1/audio-codecs/he-aac-refusal.md | 1 - rs/moq-mux/src/codec/aac/mod.rs | 391 ++++++++++++++++-- rs/moq-mux/src/container/ts/adts.rs | 20 +- rs/moq-mux/src/container/ts/import.rs | 20 +- rs/moq-mux/src/container/ts/import_test.rs | 26 ++ .../src/container/ts/test_data/aac_quad.ts | Bin 0 -> 2632 bytes 8 files changed, 409 insertions(+), 88 deletions(-) delete mode 100644 quest/m1/audio-codecs/aac-pce.md create mode 100644 rs/moq-mux/src/container/ts/test_data/aac_quad.ts diff --git a/quest/m1/audio-codecs/README.md b/quest/m1/audio-codecs/README.md index 372157036a..19251da70f 100644 --- a/quest/m1/audio-codecs/README.md +++ b/quest/m1/audio-codecs/README.md @@ -37,14 +37,11 @@ mono/stereo. The core configuration and layout contracts land in main. These quests implement surround and backend dispatch on that contract; each platform then lands as -its own decode and encode quest so verification stays per host. The -HE-AAC refusal and the PCE parse are defects in what ships today and are -ready now. +its own decode and encode quest so verification stays per host. ## Quests - [HE-AAC refusal](/quest/m1/audio-codecs/he-aac-refusal.md) - implicit-SBR HE-AAC over TS is refused instead of half-decoded as the LC core -- [AAC PCE](/quest/m1/audio-codecs/aac-pce.md) - a channel_config of 0 parses the program config element instead of guessing stereo - [Layout](/quest/m1/audio-codecs/layout.md) - the settled `Layout` carries up to 7.1 through decode, resample, playback, and the FFI - [Decode seam](/quest/m1/audio-codecs/decode-backend.md) - `decode::backend` selects a platform decoder before symphonia, mirroring moq-video - [AudioToolbox decode](/quest/m1/audio-codecs/decode-audiotoolbox.md) - macOS and iOS decode HE-AAC, multichannel AAC, and what else the framework offers diff --git a/quest/m1/audio-codecs/aac-pce.md b/quest/m1/audio-codecs/aac-pce.md deleted file mode 100644 index db99ec93f9..0000000000 --- a/quest/m1/audio-codecs/aac-pce.md +++ /dev/null @@ -1,34 +0,0 @@ -# [S] moq-mux: an AAC channel_config of 0 parses the PCE or refuses the track - -## Goal - -The catalog never invents an AAC channel count. A stream whose -channelConfiguration is 0 gets its count from the program config element it -carries, and a reserved value refuses the track with a clear error. Today -both map to stereo with a warning. - -## Plan - -The ADTS and ASC importers in `rs/moq-mux/src/codec/aac` map -`channel_config == 0` and every value from 8 to 15 to stereo and warn. -Warn-then-continue is banned: supported or refused. - -- ASC: with `channel_config == 0` the GASpecificConfig carries a - `program_config_element`; parse its front, side, back, and LFE element - counts into the channel count (and, once [Layout](/quest/m1/audio-codecs/layout.md) - lands, into a layout). -- ADTS: channel_config 0 means the PCE is in the first raw data block. Parse - it there, once per track, the same way the HE-AAC sniff reads the first - block's fill elements. -- Give every ASC configuration from 8 to 15 an explicit disposition: implement - any supported channel mapping and return `Error::Unsupported` for every - remaining value, including reserved configurations. -- Regression: an ASC fixture with a PCE reports its real count; an ADTS - fixture with an in-band PCE does too; each value from 8 to 15 has its - supported count asserted or is refused; the - common configurations 1 to 7 are unchanged. - -## Related - -- [HE-AAC refusal](/quest/m1/audio-codecs/he-aac-refusal.md) - reads the same first block -- [Layout](/quest/m1/audio-codecs/layout.md) - what the parsed PCE eventually maps to diff --git a/quest/m1/audio-codecs/he-aac-refusal.md b/quest/m1/audio-codecs/he-aac-refusal.md index f7995cc0f7..c3edf29e21 100644 --- a/quest/m1/audio-codecs/he-aac-refusal.md +++ b/quest/m1/audio-codecs/he-aac-refusal.md @@ -31,4 +31,3 @@ does too. ## Related - [Decode seam](/quest/m1/audio-codecs/decode-backend.md) - the sniff becomes the software backend's contract -- [AAC PCE](/quest/m1/audio-codecs/aac-pce.md) - the other place the catalog lies about an AAC stream diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index e2477911fc..c802530353 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -27,6 +27,21 @@ pub enum Error { #[error("unsupported sample rate index: {0}")] UnsupportedSampleRateIndex(u8), + + #[error("reserved channelConfiguration: {0}")] + ReservedChannelConfig(u8), + + #[error("channelConfiguration 0 is unsupported for audioObjectType {0}")] + ProgramConfigUnsupported(u8), + + #[error("channelConfiguration 0 without a program config element leading the first raw data block")] + ProgramConfigMissing, + + #[error("program config element truncated")] + ProgramConfigTruncated, + + #[error("program config element declares no channels")] + ProgramConfigEmpty, } pub type Result = std::result::Result; @@ -46,19 +61,16 @@ impl Config { /// (object_type == 31), and explicit sample rates (freq_index == 15). The /// fields are bit-packed and not byte-aligned, so a bit reader is required: /// with an explicit 24-bit rate the channelConfiguration lands mid-byte after - /// it. Any SBR/PS extension bits after the core fields are consumed. + /// it. A channelConfiguration of 0 takes the count from the program config element + /// that follows, and a reserved one is refused. Any SBR/PS extension bits after the + /// core fields are consumed. pub fn parse(buf: &mut T) -> Result { if buf.remaining() < 2 { return Err(Error::ConfigTooShort); } let mut reader = BitReader::new(buf); - - // audioObjectType: 5 bits, escaped to 6 more when it reads 31. - let mut object_type = reader.read(5, Error::ConfigTooShort)? as u8; - if object_type == 31 { - object_type = 32 + reader.read(6, Error::ExtendedConfigTooShort)? as u8; - } + let object_type = read_object_type(&mut reader)?; // samplingFrequencyIndex: 4 bits; index 15 means an explicit 24-bit rate follows. let freq_index = reader.read(4, Error::IncompleteConfig)? as u8; @@ -72,7 +84,36 @@ impl Config { // channelConfiguration: 4 bits, immediately after the (possibly explicit) rate. let channel_config = reader.read(4, Error::IncompleteConfig)? as u8; - let channel_count = channel_count_from_config(channel_config); + let channel_count = match channel_config { + 0 => { + // Explicit SBR and PS name their core object type after an extension rate; the + // GASpecificConfig carrying the program config element follows that core type. + let mut core = object_type; + if matches!(object_type, 5 | 29) { + if reader.read(4, Error::IncompleteConfig)? == 15 { + reader.read(24, Error::IncompleteConfig)?; + } + core = read_object_type(&mut reader)?; + if core == 22 { + // extensionChannelConfiguration, only for ER BSAC. + reader.read(4, Error::IncompleteConfig)?; + } + } + if !GENERAL_AUDIO.contains(&core) { + return Err(Error::ProgramConfigUnsupported(core)); + } + + // GASpecificConfig: frameLengthFlag, dependsOnCoreCoder (then a 14-bit + // coreCoderDelay), and extensionFlag precede the element. + reader.read(1, Error::IncompleteConfig)?; + if reader.read(1, Error::IncompleteConfig)? == 1 { + reader.read(14, Error::IncompleteConfig)?; + } + reader.read(1, Error::IncompleteConfig)?; + program_config(&mut reader)? + } + _ => channel_count_from_config(channel_config)?, + }; // AudioSpecificConfig can carry variable-length extensions (SBR, PS, etc.). // We've extracted the essential fields; drain the rest so the buffer is advanced. @@ -132,6 +173,101 @@ impl Config { } } +/// Build the AudioSpecificConfig for a stream that signals its fields per frame, as ADTS does. +/// +/// A `channel_config` of 0 means a program config element leads `block`, the stream's first raw +/// data block; it moves into the config, so the config describes the channels on its own. Any +/// other value leaves `block` unread. An element placed anywhere else is refused: reaching past +/// the channel data needs a full Huffman decode. +pub(crate) fn in_band_config(profile: u8, sample_rate: u32, channel_config: u8, block: &[u8]) -> Result { + let mut out = BitWriter::default(); + out.write(5, u32::from(profile & 0x1F)); + match SAMPLE_RATES.iter().position(|&rate| rate == sample_rate) { + Some(index) => out.write(4, index as u32), + None => { + out.write(4, 15); + out.write(24, sample_rate); + } + } + out.write(4, u32::from(channel_config)); + + if channel_config == 0 { + // GASpecificConfig: frameLengthFlag, dependsOnCoreCoder, and extensionFlag, all clear. + out.write(3, 0); + + let mut block = block; + let mut reader = BitReader::new(&mut block); + if reader.read(3, Error::ProgramConfigMissing)? != ID_PCE { + return Err(Error::ProgramConfigMissing); + } + reader.record = Some(out); + program_config(&mut reader)?; + out = reader.record.take().expect("recording set above"); + } + + Ok(Bytes::from(out.bytes)) +} + +/// The raw data block element ID of a program config element (ISO 14496-3 Table 4.85). +const ID_PCE: u32 = 5; + +/// The audioObjectTypes whose specific config is a GASpecificConfig (ISO 14496-3 §1.6.2.1), the +/// only ones where channelConfiguration 0 means a program config element follows. +const GENERAL_AUDIO: [u8; 12] = [1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23]; + +/// Read an audioObjectType: 5 bits, escaped to 6 more when it reads 31. +fn read_object_type(reader: &mut BitReader) -> Result { + let object_type = reader.read(5, Error::ConfigTooShort)? as u8; + if object_type == 31 { + return Ok(32 + reader.read(6, Error::ExtendedConfigTooShort)? as u8); + } + Ok(object_type) +} + +/// Walk a program_config_element (ISO 14496-3 Table 4.2) to the number of channels it outputs. +/// +/// Its byte alignment is relative to where the reader started: the AudioSpecificConfig, or the +/// raw data block. +fn program_config(reader: &mut BitReader) -> Result { + let mut read = |n| reader.read(n, Error::ProgramConfigTruncated); + + // element_instance_tag, object_type, sampling_frequency_index. + read(10)?; + let (front, side, back) = (read(4)?, read(4)?, read(4)?); + let (lfe, assoc, cc) = (read(2)?, read(3)?, read(4)?); + // Mono and stereo mixdowns each name an element; a matrix mixdown an index and a flag. + for bits in [4, 4, 3] { + if read(1)? == 1 { + read(bits)?; + } + } + + let mut channels = lfe; + for _ in 0..front + side + back { + // A channel pair element carries two channels, a single channel element one. + channels += 1 + read(1)?; + read(4)?; + } + // Tags of the LFE and data elements, then each coupling channel element's switch flag and tag. + for _ in 0..lfe + assoc { + read(4)?; + } + for _ in 0..cc { + read(5)?; + } + + reader.align(); + let comment = reader.read(8, Error::ProgramConfigTruncated)?; + for _ in 0..comment { + reader.read(8, Error::ProgramConfigTruncated)?; + } + + if channels == 0 { + return Err(Error::ProgramConfigEmpty); + } + Ok(channels) +} + /// The 13 standard AAC sampling frequencies, indexed by samplingFrequencyIndex /// (ISO 14496-3 Table 1.18). Index 15 is the escape for an explicit 24-bit rate. const SAMPLE_RATES: [u32; 13] = [ @@ -147,6 +283,8 @@ struct BitReader<'a, T: Buf> { buf: &'a mut T, current: u8, bits_left: u8, + /// Every bit read is copied here when set, with alignment redone on the writer's own bytes. + record: Option, } impl<'a, T: Buf> BitReader<'a, T> { @@ -155,6 +293,15 @@ impl<'a, T: Buf> BitReader<'a, T> { buf, current: 0, bits_left: 0, + record: None, + } + } + + /// Skip to the next byte boundary. + fn align(&mut self) { + self.bits_left = 0; + if let Some(record) = &mut self.record { + record.align(); } } @@ -172,27 +319,52 @@ impl<'a, T: Buf> BitReader<'a, T> { self.bits_left -= 1; value = (value << 1) | u32::from((self.current >> self.bits_left) & 1); } + if let Some(record) = &mut self.record { + record.write(n, value); + } Ok(value) } } -/// Map an AAC `channel_config` (ISO 14496-3 Table 1.19) to its real channel count. -/// Configs 1..=6 happen to be identity (5.1 has config=6 and 6 channels). Config -/// 7 is 7.1 = 8 channels. Config 0 means "described elsewhere" — we default to -/// stereo. -fn channel_count_from_config(channel_config: u8) -> u32 { - match channel_config { - 1..=6 => channel_config as u32, - 7 => 8, - 0 => { - tracing::warn!("channel_config=0 (program config element) unsupported, defaulting to stereo"); - 2 - } - _ => { - tracing::warn!(channel_config, "unsupported channel config, defaulting to stereo"); - 2 +/// MSB-first bit writer, the inverse of [`BitReader`]. +#[derive(Default)] +struct BitWriter { + bytes: Vec, + /// Bits used in the last byte; 0 when the next write starts a new one. + used: u8, +} + +impl BitWriter { + /// Write the low `n` bits (n <= 32) of `value`, MSB-first. + fn write(&mut self, n: u8, value: u32) { + for i in (0..n).rev() { + if self.used == 0 { + self.bytes.push(0); + } + let bit = ((value >> i) & 1) as u8; + *self.bytes.last_mut().expect("pushed above") |= bit << (7 - self.used); + self.used = (self.used + 1) % 8; } } + + /// Pad the last byte with zeros. + fn align(&mut self) { + self.used = 0; + } +} + +/// Map an AAC `channel_config` (ISO 14496-3 Table 1.19) to its channel count, for every value but +/// 0, which a program config element describes instead. Configs 1..=6 are identity. 7, 12, and 14 +/// are 8 channels (7.1 with wide fronts, rear surrounds, or front heights), 11 is 6.1, and 13 is +/// 22.2. The rest are reserved. +fn channel_count_from_config(channel_config: u8) -> Result { + match channel_config { + 1..=6 => Ok(channel_config as u32), + 7 | 12 | 14 => Ok(8), + 11 => Ok(7), + 13 => Ok(24), + _ => Err(Error::ReservedChannelConfig(channel_config)), + } } /// Inverse of [`channel_count_from_config`]. Defaults to stereo for unsupported @@ -281,10 +453,177 @@ mod tests { assert_eq!(parsed.channel_count, 8, "7.1 surround should round-trip as 8 channels"); } + /// The AudioSpecificConfig ffmpeg 9.0.1 writes for quad (`-af pan=quad|...`, native `aac` + /// encoder, `.m4a`): channelConfiguration 0 and a program config element with two channel + /// pair elements, front and back, then a "Lavc63.1.101" comment and an SBR sync extension. + const FFMPEG_QUAD_ASC: [u8; 24] = [ + 0x11, 0x80, 0x04, 0xC4, 0x04, 0x00, 0x21, 0x10, 0x0C, 0x4C, 0x61, 0x76, 0x63, 0x36, 0x33, 0x2E, 0x31, 0x2E, + 0x31, 0x30, 0x31, 0x56, 0xE5, 0x00, + ]; + + /// Write a program config element with `front`, `side`, and `back` elements (true for a + /// channel pair) and `lfe` LFE elements, starting at the writer's position. + fn write_pce(out: &mut BitWriter, front: &[bool], side: &[bool], back: &[bool], lfe: u32) { + // element_instance_tag, object_type (LC), sampling_frequency_index (48 kHz). + out.write(4, 0); + out.write(2, 1); + out.write(4, 3); + out.write(4, front.len() as u32); + out.write(4, side.len() as u32); + out.write(4, back.len() as u32); + out.write(2, lfe); + // No data or coupling elements, and no mixdowns. + out.write(3, 0); + out.write(4, 0); + out.write(3, 0); + for (tag, &cpe) in front.iter().chain(side).chain(back).enumerate() { + out.write(1, cpe.into()); + out.write(4, tag as u32); + } + for tag in 0..lfe { + out.write(4, tag); + } + out.align(); + // A one-byte comment. + out.write(8, 1); + out.write(8, b'x'.into()); + } + + /// An AudioSpecificConfig with channelConfiguration 0 around [`write_pce`]'s element. + fn pce_asc(object_type: u8, front: &[bool], side: &[bool], back: &[bool], lfe: u32) -> Vec { + let mut out = BitWriter::default(); + out.write(5, object_type.into()); + out.write(4, 3); + out.write(4, 0); + out.write(3, 0); + write_pce(&mut out, front, side, back, lfe); + out.bytes + } + + #[test] + fn parses_ffmpeg_program_config_element() { + let cfg = Config::parse(&mut FFMPEG_QUAD_ASC.as_slice()).unwrap(); + assert_eq!(cfg.profile, 2); + assert_eq!(cfg.sample_rate, 48_000); + assert_eq!(cfg.channel_count, 4, "two channel pair elements"); + } + + #[test] + fn parses_program_config_element_counts() { + // 5.1: a single and a pair in front, a pair in back, and an LFE. + let asc = pce_asc(2, &[false, true], &[], &[true], 1); + assert_eq!(Config::parse(&mut asc.as_slice()).unwrap().channel_count, 6); + + // Side elements count too: 3/2/2 with no LFE. + let asc = pce_asc(2, &[false, true], &[true], &[true], 0); + assert_eq!(Config::parse(&mut asc.as_slice()).unwrap().channel_count, 7); + } + + #[test] + fn parses_program_config_element_behind_explicit_sbr() { + // audioObjectType 5 (SBR), 24 kHz core, channelConfiguration 0, a 48 kHz extension + // rate, then the core type (LC) whose GASpecificConfig carries the element. + let mut out = BitWriter::default(); + out.write(5, 5); + out.write(4, 6); + out.write(4, 0); + out.write(4, 3); + out.write(5, 2); + out.write(3, 0); + write_pce(&mut out, &[true], &[], &[true], 0); + let cfg = Config::parse(&mut out.bytes.as_slice()).unwrap(); + assert_eq!(cfg.profile, 5); + assert_eq!(cfg.channel_count, 4); + } + + #[test] + fn refuses_bad_program_config_elements() { + let asc = pce_asc(2, &[], &[], &[], 0); + assert!(matches!( + Config::parse(&mut asc.as_slice()), + Err(Error::ProgramConfigEmpty) + )); + + let asc = pce_asc(2, &[true], &[], &[], 0); + assert!(matches!( + Config::parse(&mut &asc[..asc.len() - 1]), + Err(Error::ProgramConfigTruncated) + )); + + // ALS (36) describes its channels in its own specific config, not a PCE. + let mut out = BitWriter::default(); + out.write(5, 31); + out.write(6, 4); + out.write(4, 3); + out.write(4, 0); + out.write(8, 0); + assert!(matches!( + Config::parse(&mut out.bytes.as_slice()), + Err(Error::ProgramConfigUnsupported(36)) + )); + } + + #[test] + fn channel_config_dispositions() { + let expected = [1, 2, 3, 4, 5, 6, 8]; + for (config, count) in (1..=7).zip(expected) { + let asc = [0x11, 0x80 | (config << 3)]; + assert_eq!(Config::parse(&mut asc.as_slice()).unwrap().channel_count, count); + } + + for (config, count) in [(11, 7), (12, 8), (13, 24), (14, 8)] { + let asc = [0x11, 0x80 | (config << 3)]; + assert_eq!(Config::parse(&mut asc.as_slice()).unwrap().channel_count, count); + } + + for config in [8, 9, 10, 15] { + let asc = [0x11, 0x80 | (config << 3)]; + assert!(matches!( + Config::parse(&mut asc.as_slice()), + Err(Error::ReservedChannelConfig(c)) if c == config + )); + } + } + + #[test] + fn in_band_config_moves_the_program_config_element() { + // A raw data block leading with ID_PCE, which puts the element 3 bits off the byte grid + // the AudioSpecificConfig puts it on, so the copy has to redo the alignment. + let mut block = BitWriter::default(); + block.write(3, ID_PCE); + write_pce(&mut block, &[false, true], &[], &[true], 1); + // The channel elements that follow are never read. + block.write(8, 0xFF); + + let asc = in_band_config(2, 48_000, 0, &block.bytes).unwrap(); + assert_eq!(asc, pce_asc(2, &[false, true], &[], &[true], 1)); + assert_eq!(Config::parse(&mut asc.as_ref()).unwrap().channel_count, 6); + } + #[test] - fn channel_config_zero_falls_back_to_stereo() { - // Config 0 means "described in PCE" which we don't implement. - assert_eq!(channel_count_from_config(0), 2); + fn in_band_config_refuses_a_block_without_a_leading_pce() { + // ID_CPE first: any element past the channel data is out of reach. + assert!(matches!( + in_band_config(2, 48_000, 0, &[0x20, 0x00]), + Err(Error::ProgramConfigMissing) + )); + assert!(matches!( + in_band_config(2, 48_000, 0, &[]), + Err(Error::ProgramConfigMissing) + )); + } + + #[test] + fn in_band_config_matches_encode() { + // Without a PCE the block is unread and the config is the plain two-byte form. + let asc = in_band_config(2, 44_100, 2, &[]).unwrap(); + let encoded = Config { + profile: 2, + sample_rate: 44_100, + channel_count: 2, + } + .encode(); + assert_eq!(asc, encoded); } #[test] diff --git a/rs/moq-mux/src/container/ts/adts.rs b/rs/moq-mux/src/container/ts/adts.rs index b5a986a2f3..0166c05937 100644 --- a/rs/moq-mux/src/container/ts/adts.rs +++ b/rs/moq-mux/src/container/ts/adts.rs @@ -21,7 +21,8 @@ pub(super) struct Header { /// audioObjectType (ADTS `profile` + 1). AAC-LC is 2. pub object_type: u8, pub sample_rate: u32, - pub channel_count: u32, + /// channelConfiguration; 0 means a program config element in the raw data block describes the channels. + pub channel_config: u8, /// Total access-unit length, header included. pub frame_len: usize, /// Header length: 7 without CRC, 9 with. @@ -51,7 +52,7 @@ impl Header { Ok(Self { object_type: profile + 1, sample_rate, - channel_count: channel_count_from_config(channel_config), + channel_config, frame_len, header_len, }) @@ -96,16 +97,7 @@ fn freq_index_from_rate(sample_rate: u32) -> anyhow::Result { .with_context(|| format!("sample rate {sample_rate} not representable in ADTS")) } -/// Map an AAC `channel_config` (ISO 14496-3 Table 1.19) to a channel count. -fn channel_count_from_config(channel_config: u8) -> u32 { - match channel_config { - 1..=6 => channel_config as u32, - 7 => 8, - _ => 2, - } -} - -/// Inverse of [`channel_count_from_config`]. +/// Map a channel count to an AAC `channel_config` (ISO 14496-3 Table 1.19). fn channel_config_from_count(channel_count: u32) -> u8 { match channel_count { 1..=6 => channel_count as u8, @@ -126,7 +118,7 @@ mod tests { assert_eq!(parsed.object_type, 2); assert_eq!(parsed.sample_rate, 48_000); - assert_eq!(parsed.channel_count, 2); + assert_eq!(parsed.channel_config, 2); assert_eq!(parsed.header_len, 7); assert_eq!(parsed.frame_len, 107, "frame_len includes the 7-byte header"); } @@ -142,7 +134,7 @@ mod tests { fn frame_len_for_5_1() { let header = write_header(2, 44_100, 6, 512).unwrap(); let parsed = Header::parse(&header).unwrap(); - assert_eq!(parsed.channel_count, 6); + assert_eq!(parsed.channel_config, 6); assert_eq!(parsed.sample_rate, 44_100); assert_eq!(parsed.frame_len, 519); } diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index fa5daf8aa7..d0886cddd1 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -2064,21 +2064,23 @@ impl AacStream { let import = match &mut self.import { Some(import) => import, None => { - let config = aac::Config { - profile: header.object_type, - sample_rate: header.sample_rate, - channel_count: header.channel_count, - }; + // Synthesize the AudioSpecificConfig `description` so out-of-band consumers + // (fMP4/MKV export, WebCodecs) can configure the decoder. A channel_config of 0 + // moves the program config element out of this first frame into it. + let asc = aac::in_band_config( + header.object_type, + header.sample_rate, + header.channel_config, + &data[offset + header.header_len..end], + )?; + let mut config = aac::config(&asc)?; + config.container = self.container.clone(); // Consume the reservation held since the PMT: this resolves the gated rendition, // and carries the catalog's declared media retention onto the track. - // The importer synthesizes the AudioSpecificConfig `description` from the config so - // out-of-band consumers (fMP4/MKV export, WebCodecs) can configure the decoder. let reserved = self.reserved.take().expect("aac reservation already consumed"); let track = self .broadcast .unique_track(".aac", reserved.track_info(hang::catalog::PRIORITY.audio))?; - let mut config: hang::catalog::AudioConfig = config.into(); - config.container = self.container.clone(); let aac = aac::Import::new(track, reserved, config)?; self.import.insert(aac) } diff --git a/rs/moq-mux/src/container/ts/import_test.rs b/rs/moq-mux/src/container/ts/import_test.rs index 348f2cf926..d0c48534ac 100644 --- a/rs/moq-mux/src/container/ts/import_test.rs +++ b/rs/moq-mux/src/container/ts/import_test.rs @@ -156,6 +156,32 @@ fn import_ac3_catalog() { assert!(audio.description.is_none(), "verbatim AC-3 needs no description"); } +/// `aac_quad.ts` is an ffmpeg-authored audio-only AAC program in quad, which has no +/// channelConfiguration, so its ADTS headers carry 0 and the first raw data block leads with a +/// program config element. Regenerated with (ffmpeg 9.0.1): +/// `ffmpeg -f lavfi -i sine=frequency=440:sample_rate=48000:duration=0.1 +/// -af "pan=quad|FL=c0|FR=c0|BL=c0|BR=c0" -c:a aac -b:a 128k -f mpegts aac_quad.ts`. +#[test] +fn import_aac_program_config_catalog() { + let data = include_bytes!("test_data/aac_quad.ts"); + let catalog = import_ts(data); + + assert_eq!(catalog.audio.renditions.len(), 1, "expected one AAC track"); + let audio = catalog.audio.renditions.values().next().unwrap(); + assert_eq!(audio.codec.to_string(), "mp4a.40.2"); + assert_eq!(audio.sample_rate, 48_000); + assert_eq!( + audio.channel_count, 4, + "two channel pair elements, not a guessed stereo" + ); + + // The element moved into the description is byte-for-byte what ffmpeg itself writes as the + // AudioSpecificConfig for the same stream in MP4, minus the trailing SBR sync extension. + let mut expected = vec![0x11, 0x80, 0x04, 0xC4, 0x04, 0x00, 0x21, 0x10, 0x0C]; + expected.extend_from_slice(b"Lavc63.1.101"); + assert_eq!(audio.description.as_deref(), Some(expected.as_slice())); +} + /// `opus.ts` is an ffmpeg-authored audio-only Opus program (private stream_type 0x06 /// plus the 'Opus' registration and DVB extension descriptors), generated with: /// `ffmpeg -f lavfi -i sine=frequency=440:sample_rate=48000:duration=0.5 diff --git a/rs/moq-mux/src/container/ts/test_data/aac_quad.ts b/rs/moq-mux/src/container/ts/test_data/aac_quad.ts new file mode 100644 index 0000000000000000000000000000000000000000..17aff4786200068fa22e9b89662f19d2ce14ce7f GIT binary patch literal 2632 zcmd6oc~nzZ8o*x&5@etu7#5`(Ng_s&%_VhE*-QwKxUk4naAXTh6l4I|B#;EqAuL4< z5-JL5hDE0Aj0<5qKn@^P>0Xgo^1D+$nimvDz#4H10Fty* z004lA5yfowCKCWL8~`e4fVp%{P{?MhW3_1kaY4lWmNu3));6QS#wWn(l$NpEVVdK! zLtT>|J-0*HOm*J0JUoLUa8M9t8%V{u?Ge*ho-{ zTWH&EwqJz(p)$HUESAKRMLW>Tc;186b@kH1#g_${^a|ghs{`T<@r1+4DI$h%)x$gY z77@96dxH6UAvt%lqYGu`k*!x~3VFn1%BTM=7yE|YMK!Ii@C^tO*~so%pu0}ouhuE! zY=}HK+?W_XybtdJF(dRKcRjpEkCkedk7CwL+`^BaF>mOKr8lSE{%m#m^@tB2%*BXq zU&uKpQd;?a=(H=3%=!#fMV#nuo-R(fU{R2C#ZV-1Xl+IeTuCNiPL?;q7FLo8@R@~` z3+qWBMpg{YRQ0kC_WA7fDOE-^lT5fJgfHj0C06;Q*7@ZqZrarRYZ}Ii-%ZGX%`Y<_ zwCk$%6gP5AHpFhzs{9`tp7h;wOHSp@vLN>@qGFwDDp-*#ead;CoT9vhT=koL$|hEX zjK%c*;^k8Jj%C4D`fi)9Hn8wu5y!B)ZGHK)Wm1pj<)`ZDd1XY?sv8n|bHkqY3|MgA8S%9Z!Q*+m zB}(X_BfIQcU4Ifu`!t5iz`;)0w1cum#Ii-auT@Mt7<)EeY?{9N!K?K1IQxPg&>|;h zD9fPIzUX3}W?*QyDN`8CKN_m$l%P8A9e!&j32hR;&%4%GCtuVY`myaTqr8?HIa=_w z8wb2Puv&uIS`;rG#Y~QOEiS?*$C0cAN@qs^wmW z@K0s;=cuR49Kgqnk6ZoKb)vsXrnFaDt%;=Xr>M`ZY$cSG zhYv=bHHF*@{XweF)bNr!iHvq}5+-X_YMR+r-O}u^QTNnikZc`4xzfi{fC|=>5|o*p zt4ti~KK#>I_o!Pd+TG%^c9?4MxlBTakgMr+@+-*QP~T(|0b#2qyf3=y+{SMpMtFZ6iR4hr&!OLFlBv<$JP=qx*CEgjjX@yIIg4v}^f! zJMOoiQI;fI$~@j&cm33zfj=q6G98v?Gn^$Hxiym%oQ-626{rGhJFVSq6c_Olp#-%Z z2OqDQW#WzLLh4)16qIO%RWZbmuZ*T%r)c}QF0fk_@W~nz;}FDfs{(b;p9#4eX=6}Z zgW%&rCh1Z(G8IA&SlhwJMCzTafH^6f_^Nwud1;sLd7M$($aY)>R=;lr%*~*F*R-n( zA5)EAa`?8P_~{j1cymneuiW2au4^tvw%SOClkSh}+;4c;lCs(RWo~TCW@CC$P-*P> zTVUt}ubICPzqwy!(M*3dIH@^<5^5o`)({8X2f9tEG}s;f3H6bMr@Z)#1Ielg?a%Mg^7 zevv9P3){2D>Yl3gI`BzOW)b!)LH}MaiN0Im^BXOn9U8jX6isc*>%tC@fJb-k5Cp>V z)FuY5`Lw}HXd+H<2jaO=e^9mhI`F>*mOr@@cHj^CL^3KnFsGdSE90fhzw#JZE%~;P zz4#-LyX|4%|NqT1Is(Ov4(n?QHx*CgmPZp3UAw7VHE6CHJe#<{JWPy?AAcr)C;3L2 zMrp^kI1)zBIokF~8;$DE6TzBGEODD$-5! Qn0E6##`H6}Y$K`j-^lX>rT_o{ literal 0 HcmV?d00001 From 18c317a287ff85be798818ea74155161dd8692a9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 22:28:00 -0700 Subject: [PATCH 3/4] docs(quest): plan the TS export PCE and the PCE layout mapping Co-Authored-By: Claude Opus 5.5 --- quest/m1/audio-codecs/README.md | 1 + quest/m1/audio-codecs/layout.md | 5 ++++- quest/m1/audio-codecs/ts-export-pce.md | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 quest/m1/audio-codecs/ts-export-pce.md diff --git a/quest/m1/audio-codecs/README.md b/quest/m1/audio-codecs/README.md index 19251da70f..c1366d831b 100644 --- a/quest/m1/audio-codecs/README.md +++ b/quest/m1/audio-codecs/README.md @@ -42,6 +42,7 @@ its own decode and encode quest so verification stays per host. ## Quests - [HE-AAC refusal](/quest/m1/audio-codecs/he-aac-refusal.md) - implicit-SBR HE-AAC over TS is refused instead of half-decoded as the LC core +- [TS export PCE](/quest/m1/audio-codecs/ts-export-pce.md) - a TS export of a PCE-described AAC track writes channel_config 0 and the PCE instead of a count-derived config - [Layout](/quest/m1/audio-codecs/layout.md) - the settled `Layout` carries up to 7.1 through decode, resample, playback, and the FFI - [Decode seam](/quest/m1/audio-codecs/decode-backend.md) - `decode::backend` selects a platform decoder before symphonia, mirroring moq-video - [AudioToolbox decode](/quest/m1/audio-codecs/decode-audiotoolbox.md) - macOS and iOS decode HE-AAC, multichannel AAC, and what else the framework offers diff --git a/quest/m1/audio-codecs/layout.md b/quest/m1/audio-codecs/layout.md index 2df431c95c..e92dfbbe38 100644 --- a/quest/m1/audio-codecs/layout.md +++ b/quest/m1/audio-codecs/layout.md @@ -16,7 +16,10 @@ Keep the representation extensible. Each codec module maps its native order into it: AAC's `C L R Ls Rs LFE` and Opus's Vorbis `L C R Ls Rs LFE` both become `L R C LFE Ls Rs`. An unspecified discrete PCM layout remains valid for passthrough but is refused for spatial remixing; never invent speaker -positions from an arbitrary channel count. +positions from an arbitrary channel count. An AAC stream with +channelConfiguration 0 takes its layout from the program config element's +front, side, back, and LFE elements, which `moq_mux::codec::aac` already parses +for the channel count. - Reuse the settled PCM descriptors and codec/consumer settings. This quest adds supported layout behavior rather than replacing public field types. diff --git a/quest/m1/audio-codecs/ts-export-pce.md b/quest/m1/audio-codecs/ts-export-pce.md new file mode 100644 index 0000000000..31e9b76b25 --- /dev/null +++ b/quest/m1/audio-codecs/ts-export-pce.md @@ -0,0 +1,17 @@ +# [S] TS export carries an AAC track's program config element + +## Goal + +`moq-mux`'s MPEG-TS export writes an AAC track described by a program config +element (channelConfiguration 0) as ADTS with channel_config 0 and the PCE at +the start of the first raw data block, so a TS round trip keeps the layout. +Today the export derives the ADTS channel_config from the channel count, which +mislabels such a track. + +## Plan + +Take the PCE from the track's AudioSpecificConfig description with the parser +that reads it on import, and write it as ffmpeg does: once, leading the first +raw data block. Tracks with a nonzero channelConfiguration are unchanged. Test +a round trip of the quad fixture from the PCE import (`aac_quad.ts`): import, +export, and import again, asserting the same description and channel count. From bf343555f20e17fce068cba00beb61dea887dd79 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 23:41:23 -0700 Subject: [PATCH 4/4] docs(mux): document the new AAC error variants Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/codec/aac/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index c802530353..6d7f7cb67a 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -28,18 +28,23 @@ pub enum Error { #[error("unsupported sample rate index: {0}")] UnsupportedSampleRateIndex(u8), + /// The channelConfiguration is a reserved value (8, 9, 10, or 15). #[error("reserved channelConfiguration: {0}")] ReservedChannelConfig(u8), + /// A program config element is used with an object type that has no GASpecificConfig. #[error("channelConfiguration 0 is unsupported for audioObjectType {0}")] ProgramConfigUnsupported(u8), + /// ADTS signals a program config element, but none leads the first raw data block. #[error("channelConfiguration 0 without a program config element leading the first raw data block")] ProgramConfigMissing, + /// The program config element ends before its declared fields. #[error("program config element truncated")] ProgramConfigTruncated, + /// The program config element declares zero channels. #[error("program config element declares no channels")] ProgramConfigEmpty, }