From 50c749017c7c747baf7ecb6bde44f0d548eed51d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 00:15:54 -0700 Subject: [PATCH 1/4] quest: claim he-aac-catalog-output Co-Authored-By: Claude Opus 5.5 From 4de95d87a98861f0a8f3a31ed424190e2e2aa22f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 00:26:24 -0700 Subject: [PATCH 2/4] fix(moq-mux): HE-AAC catalog entries name the output, not the LC core aac::Config::parse reports the SBR extension rate under explicit SBR and stereo under PS, so the FLV, MKV, and MSF paths agree with fMP4 and ffprobe. encode writes the explicit hierarchical form back. MKV export writes the core as SamplingFrequency and the output as OutputSamplingFrequency, and the fMP4 trailing-frame fallback spans 2048 output samples for HE-AAC. Co-Authored-By: Claude Opus 5.5 --- drafts/draft-lcurley-moq-hang.md | 2 + quest/m1/README.md | 1 - quest/m1/he-aac-catalog-output.md | 29 ---- rs/moq-audio/src/aac.rs | 4 +- rs/moq-mux/src/catalog/msf/consumer.rs | 17 ++ rs/moq-mux/src/codec/aac/mod.rs | 164 ++++++++++++-------- rs/moq-mux/src/container/flv/import_test.rs | 24 +++ rs/moq-mux/src/container/fmp4/export.rs | 3 +- rs/moq-mux/src/container/fmp4/mod.rs | 21 +++ rs/moq-mux/src/container/fmp4/muxer.rs | 3 +- rs/moq-mux/src/container/mkv/export.rs | 16 +- rs/moq-mux/src/container/mkv/export_test.rs | 39 +++++ 12 files changed, 217 insertions(+), 106 deletions(-) delete mode 100644 quest/m1/he-aac-catalog-output.md diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 38dc8567153..87cc2669157 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -253,6 +253,7 @@ type AudioSchema = { The `renditions` field contains a map of track names to audio decoder configurations. See the [WebCodecs specification](https://www.w3.org/TR/webcodecs/#audio-decoder-config) for specifics and registered codecs. Any field carrying raw bytes, notably `description`, is a hex string ({{binary}}). +The `sampleRate` and `numberOfChannels` fields describe the decoded output: for HE-AAC, the SBR rate rather than the core's, and two channels under parametric stereo. In addition to the WebCodecs fields, each rendition MAY carry the common rendition fields ({{common}}). @@ -1068,6 +1069,7 @@ This document has no IANA actions. - One cutting rule for every track: a record ends at the first group boundary past a minimum (2 seconds RECOMMENDED, zero for sparse data such as a catalog) and splits a group between frames at a maximum (10 seconds RECOMMENDED), so a group that never closes is indexed as it grows and `durationMax` bounds every record. - Recording format version 2: each track stores record N at `segments/N`, beside its timeline's `segments/N`, with a `Frame Start` field in the segment object. Tracks commit and expire independently, and a DVR keeps each track's newest record. - Described deriving HLS and DASH at the edge from a reference rendition's records. +- Audio `sampleRate` and `numberOfChannels` describe the decoded output, so HE-AAC names its SBR rate and parametric stereo names two channels. ## moq-hang-03 {:numbered="false"} diff --git a/quest/m1/README.md b/quest/m1/README.md index 21d2307fea9..3c069c0e489 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -105,7 +105,6 @@ blocks. The quests that gated m0 lines moved under them. - [Retire the libmoq stub](/quest/m1/libmoq-retire.md) - the published `libmoq` crate stops after its final release points users at `moq-c` - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - replace FFmpeg video and audio decoding with moq-video and moq-audio, deliver GPU frames, and use native audio/video encoders - [AudioToolbox decode](/quest/m1/audio-decode-audiotoolbox.md) - macOS and iOS decode HE-AAC, multichannel AAC, and what else the framework offers -- [HE-AAC catalog output](/quest/m1/he-aac-catalog-output.md) - HE-AAC catalog entries name the output rate and layout, not the LC core - [TS Opus export refusals](/quest/m1/ts-opus-export-refusals.md) - the TS exporter refuses Opus heads its channel code cannot describe instead of mislabeling them - [TS Opus channel codes](/quest/m1/ts-opus-channel-codes.md) - TS Opus with a channel code of 0x81 or above imports with its real head or is refused, never guessed as stereo - [AudioToolbox encode](/quest/m1/audio-encode-audiotoolbox.md) - macOS and iOS encode AAC-LC diff --git a/quest/m1/he-aac-catalog-output.md b/quest/m1/he-aac-catalog-output.md deleted file mode 100644 index 9032995d6e9..00000000000 --- a/quest/m1/he-aac-catalog-output.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] HE-AAC catalog entries describe the output - -## Goal - -A catalog entry for HE-AAC or HE-AACv2 names what the stream plays as, for -example 48 kHz stereo, not its AAC-LC core (24 kHz, and mono for v2). Players -and gateways that size buffers or pick a decoder from the catalog see the real -output. - -## Plan - -- `Config::parse` reports the core rate and channel count because those lead - an explicit SBR or PS AudioSpecificConfig. The FLV and MKV importers copy - them into the catalog; fMP4 takes the sample entry instead, so the importers - disagree for the same stream. -- Derive the output from the config: the extension rate under SBR, two - channels under PS. Implicit SBR, found only in band, cannot be known from the - config; keep the documented half-rate behavior for it. -- Consumers that rebuild a config from catalog fields (description synthesis in - `moq-audio`, the MSF path) must not then mistake the output rate for the core. -- Regression: the `fdkaacenc` HE-AAC and HE-AACv2 FLV fixtures under - `rs/moq-mux/src/container/ts/test_data` import as 48 kHz stereo, matching - ffprobe. - -Public API: possibly `Config` fields. Wire: catalog values change for HE-AAC. - -## Related - -- [#4283](https://github.com/moq-dev/moq/pull/4283) - found while labeling HE-AAC for ADTS diff --git a/rs/moq-audio/src/aac.rs b/rs/moq-audio/src/aac.rs index 52fb66e3dd3..9eba851658b 100644 --- a/rs/moq-audio/src/aac.rs +++ b/rs/moq-audio/src/aac.rs @@ -284,8 +284,8 @@ mod tests { #[test] fn rejects_description_disagreeing_with_the_codec_string() { // The catalog claims LC while the description says HE-AAC; the description wins. - let mut config = catalog(2, 44_100, 2); - config.description = Some(bytes::Bytes::from_static(&[0x2A, 0x10])); + let mut config = catalog(2, 48_000, 2); + config.description = Some(bytes::Bytes::from_static(&[0x2B, 0x11, 0x88, 0x00])); assert!(matches!(description(&config, 2), Err(Error::Unsupported(_)))); } diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index a7582392a71..8e0d7ad6c72 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -700,6 +700,23 @@ mod test { assert_eq!(audio.channel_count, 2); } + #[test] + fn aac_derivation_names_the_he_aac_v2_output() { + // fdkaacenc HE-AACv2 at 48 kHz stereo: PS over a 24 kHz mono LC core. + let asc = [0xEBu8, 0x09, 0x88, 0x00]; + let mut track = audio_track("audio0", moq_msf::Packaging::Legacy); + track.codec = Some("mp4a.40.29".to_string()); + track.samplerate = None; + track.channel_config = None; + track.init_data = Some(base64::engine::general_purpose::STANDARD.encode(asc)); + let msf = moq_msf::Catalog::new(vec![track]); + + let catalog = from_msf::<()>(&msf).expect("AAC AudioSpecificConfig should parse"); + let audio = catalog.audio.renditions.get("audio0").expect("audio0 rendition"); + assert_eq!(audio.sample_rate, 48_000); + assert_eq!(audio.channel_count, 2); + } + #[test] fn audio_only_channels_missing_uses_explicit_samplerate() { // Half the fields missing: trust the explicit one, derive the missing one. diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index ad46c1773b9..c48ea913ff8 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -54,8 +54,11 @@ pub type Result = std::result::Result; /// Typed AAC configuration mirroring the relevant fields of an /// AudioSpecificConfig. pub struct Config { + /// The leading audioObjectType: 2 for AAC-LC, 5 for HE-AAC (SBR), 29 for HE-AACv2 (PS). pub profile: u8, + /// The decoded output rate: under SBR, the extension rate rather than the core's. pub sample_rate: u32, + /// The decoded output channels: two under PS, which a mono core carries. pub channel_count: u32, } @@ -67,19 +70,27 @@ impl Config { /// 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. 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. + /// that follows, and a reserved one is refused. + /// + /// Explicit SBR and PS report what the stream plays as, the extension rate and stereo, not + /// their AAC-LC core. Implicit SBR, found only in band, reports the core. Any extension bits + /// past the core fields are consumed. pub fn parse(buf: &mut T) -> Result { let mut reader = BitReader::new(buf); - let (object_type, sample_rate, channel_config) = read_header(&mut reader)?; + let (object_type, core_rate, channel_config) = read_header(&mut reader)?; + let (core, sample_rate) = read_core(&mut reader, object_type, core_rate)?; let channel_count = match channel_config { 0 => { - let core = read_core(&mut reader, object_type)?; read_general_audio(&mut reader, core)?; program_config(&mut reader)? } _ => channel_count_from_config(channel_config)?, }; + let channel_count = if object_type == OBJECT_TYPE_PS { + 2 + } else { + channel_count + }; // AudioSpecificConfig can carry variable-length extensions (SBR, PS, etc.). // We've extracted the essential fields; drain the rest so the buffer is advanced. @@ -97,45 +108,31 @@ impl Config { /// Encode this configuration as an AudioSpecificConfig (ISO 14496-3 §1.6.2.1). /// /// Standard sample rates produce 2 bytes; non-standard rates fall back to - /// the 5-byte form with an explicit 24-bit frequency. + /// the 5-byte form with an explicit 24-bit frequency. SBR and PS are signaled explicitly + /// over an AAC-LC core at half the output rate, mono under PS. pub fn encode(&self) -> Bytes { - // audioObjectType is a 5-bit field; mask to prevent shift overflow. + // audioObjectType is a 5-bit field; a wider type is masked, not escaped. let profile = self.profile & 0x1F; - let freq_index: u8 = match self.sample_rate { - 96000 => 0, - 88200 => 1, - 64000 => 2, - 48000 => 3, - 44100 => 4, - 32000 => 5, - 24000 => 6, - 22050 => 7, - 16000 => 8, - 12000 => 9, - 11025 => 10, - 8000 => 11, - 7350 => 12, - _ => 0xF, // explicit 24-bit frequency follows - }; - - let channel_config = channel_config_from_count(self.channel_count) as u64; - - if freq_index != 0xF { - // 5 + 4 + 4 = 13 bits → 2 bytes (3 bits padding) - let b0 = (profile << 3) | (freq_index >> 1); - let b1 = ((freq_index & 1) << 7) | ((channel_config as u8 & 0x0F) << 3); - Bytes::from(vec![b0, b1]) + let mut out = BitWriter::default(); + out.write(5, profile.into()); + if matches!(profile, OBJECT_TYPE_SBR | OBJECT_TYPE_PS) { + let channel_count = if profile == OBJECT_TYPE_PS { + 1 + } else { + self.channel_count + }; + write_sample_rate(&mut out, self.sample_rate / 2); + out.write(4, channel_config_from_count(channel_count).into()); + write_sample_rate(&mut out, self.sample_rate); + out.write(5, OBJECT_TYPE_LC.into()); } else { - // 5 + 4 + 24 + 4 = 37 bits → 5 bytes (3 bits padding) - let mut bits: u64 = 0; - bits |= (profile as u64) << 35; - bits |= 0xF_u64 << 31; - bits |= (self.sample_rate as u64) << 7; - bits |= (channel_config & 0xF) << 3; - let all = bits.to_be_bytes(); - Bytes::copy_from_slice(&all[3..8]) + write_sample_rate(&mut out, self.sample_rate); + out.write(4, channel_config_from_count(self.channel_count).into()); } + // GASpecificConfig: frameLengthFlag, dependsOnCoreCoder, and extensionFlag, all clear. + out.write(3, 0); + Bytes::from(out.bytes) } } @@ -147,36 +144,49 @@ fn read_header(reader: &mut BitReader) -> Result<(u8, u32, u8)> { } let object_type = read_object_type(reader)?; - // samplingFrequencyIndex: 4 bits; index 15 means an explicit 24-bit rate follows. - let freq_index = reader.read(4, Error::IncompleteConfig)? as u8; - let sample_rate = if freq_index == 15 { - reader.read(24, Error::ExplicitSampleRateTooShort)? - } else { - *SAMPLE_RATES - .get(freq_index as usize) - .ok_or(Error::UnsupportedSampleRateIndex(freq_index))? - }; + let sample_rate = read_sample_rate(reader)?; // channelConfiguration: 4 bits, immediately after the (possibly explicit) rate. let channel_config = reader.read(4, Error::IncompleteConfig)? as u8; Ok((object_type, sample_rate, channel_config)) } -/// Read the core audioObjectType, which explicit SBR and PS name after an extension rate. Any -/// other object type is its own core, and the leading sample rate is always the core's. -fn read_core(reader: &mut BitReader, object_type: u8) -> Result { - if !matches!(object_type, 5 | 29) { - return Ok(object_type); - } - if reader.read(4, Error::IncompleteConfig)? == 15 { - reader.read(24, Error::IncompleteConfig)?; +/// Read the core audioObjectType and the output rate, which explicit SBR and PS name ahead of +/// their core. Any other object type is its own core at the leading `sample_rate`. +fn read_core(reader: &mut BitReader, object_type: u8, sample_rate: u32) -> Result<(u8, u32)> { + if !matches!(object_type, OBJECT_TYPE_SBR | OBJECT_TYPE_PS) { + return Ok((object_type, sample_rate)); } + let output_rate = read_sample_rate(reader)?; let core = read_object_type(reader)?; if core == 22 { // extensionChannelConfiguration, only for ER BSAC. reader.read(4, Error::IncompleteConfig)?; } - Ok(core) + Ok((core, output_rate)) +} + +/// Read a samplingFrequencyIndex, escaped to an explicit 24-bit rate when it reads 15. +fn read_sample_rate(reader: &mut BitReader) -> Result { + let index = reader.read(4, Error::IncompleteConfig)? as u8; + if index == 15 { + return reader.read(24, Error::ExplicitSampleRateTooShort); + } + SAMPLE_RATES + .get(index as usize) + .copied() + .ok_or(Error::UnsupportedSampleRateIndex(index)) +} + +/// Write a samplingFrequencyIndex, or the escape and the low 24 bits of a rate the table lacks. +fn write_sample_rate(out: &mut BitWriter, sample_rate: u32) { + 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); + } + } } /// Read a GASpecificConfig up to the program config element that a channelConfiguration of 0 @@ -204,13 +214,7 @@ fn read_general_audio(reader: &mut BitReader, core: u8) -> Result<()> pub(crate) fn in_band_config(profile: u8, sample_rate: u32, channel_config: u8, block: &mut &[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); - } - } + write_sample_rate(&mut out, sample_rate); out.write(4, u32::from(channel_config)); if channel_config == 0 { @@ -250,7 +254,7 @@ pub(crate) fn in_band(asc: &[u8]) -> Result { let mut asc = asc; let mut reader = BitReader::new(&mut asc); let (object_type, sample_rate, channel_config) = read_header(&mut reader)?; - let core = read_core(&mut reader, object_type)?; + let (core, _) = read_core(&mut reader, object_type, sample_rate)?; let program_config = match channel_config { 0 => { @@ -272,6 +276,11 @@ pub(crate) fn in_band(asc: &[u8]) -> Result { }) } +/// The audioObjectTypes of AAC-LC, of SBR (HE-AAC), and of SBR with PS (HE-AACv2). +const OBJECT_TYPE_LC: u8 = 2; +const OBJECT_TYPE_SBR: u8 = 5; +const OBJECT_TYPE_PS: u8 = 29; + /// The raw data block element ID of a program config element (ISO 14496-3 Table 4.85). const ID_PCE: u32 = 5; @@ -601,9 +610,32 @@ mod tests { fn parses_program_config_element_behind_explicit_sbr() { let cfg = Config::parse(&mut sbr_pce_asc().as_slice()).unwrap(); assert_eq!(cfg.profile, 5); + assert_eq!(cfg.sample_rate, 48_000, "the extension rate"); assert_eq!(cfg.channel_count, 4); } + /// GStreamer 1.28 `fdkaacenc` at 48 kHz stereo: HE-AAC is SBR over a 24 kHz stereo LC core, and + /// HE-AACv2 is PS over a mono one. + const FDKAAC_HE_AAC: [u8; 4] = [0x2B, 0x11, 0x88, 0x00]; + const FDKAAC_HE_AAC_V2: [u8; 4] = [0xEB, 0x09, 0x88, 0x00]; + + #[test] + fn explicit_sbr_and_ps_parse_as_their_output() { + for (asc, profile) in [(FDKAAC_HE_AAC, 5), (FDKAAC_HE_AAC_V2, 29)] { + let cfg = Config::parse(&mut asc.as_slice()).unwrap(); + assert_eq!(cfg.profile, profile); + assert_eq!(cfg.sample_rate, 48_000, "the output rate, not the 24 kHz core"); + assert_eq!(cfg.channel_count, 2, "stereo, including over a mono PS core"); + assert_eq!(cfg.encode(), asc.as_slice(), "encode writes the core back"); + } + + // A config naming SBR but stopping before its extension rate has no output to name. + assert!(matches!( + Config::parse(&mut [0x2A, 0x10].as_slice()), + Err(Error::IncompleteConfig) + )); + } + #[test] fn refuses_bad_program_config_elements() { let asc = pce_asc(2, &[], &[], &[], 0); @@ -698,9 +730,7 @@ mod tests { #[test] fn in_band_of_explicit_sbr_is_its_lc_core() { - // GStreamer 1.28 `fdkaacenc` at 48 kHz stereo: HE-AAC is SBR over a 24 kHz stereo LC core, - // and HE-AACv2 is PS over a mono one. - for (asc, channel_config) in [([0x2B, 0x11, 0x88, 0x00], 2), ([0xEB, 0x09, 0x88, 0x00], 1)] { + for (asc, channel_config) in [(FDKAAC_HE_AAC, 2), (FDKAAC_HE_AAC_V2, 1)] { let expected = InBand { object_type: 2, sample_rate: 24_000, diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index a2fefd9a1fd..a0fb63a54bd 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -509,6 +509,30 @@ async fn import_enhanced_av1() { assert_eq!(frame.payload.as_ref(), payload); } +/// GStreamer 1.28 `fdkaacenc` output at 48 kHz stereo, remuxed to FLV by ffmpeg 9.0.1, whose +/// AudioSpecificConfig signals SBR (and PS for v2) over a 24 kHz LC core. The catalog names the +/// output, as ffprobe does, not the core. +#[tokio::test(start_paused = true)] +async fn import_explicit_sbr_names_its_output() { + let fixtures: [(&[u8], u8); 2] = [ + (include_bytes!("../ts/test_data/he_aac.flv"), 5), + (include_bytes!("../ts/test_data/he_aac_v2.flv"), 29), + ]; + for (data, profile) in fixtures { + let mut producer = moq_net::broadcast::Info::new().produce(); + let catalog = crate::catalog::Producer::new(&mut producer, crate::catalog::Config::default()).unwrap(); + let mut importer = Import::new(producer, catalog.reserve()); + importer.decode(&bytes::BytesMut::from(data)).unwrap(); + importer.finish().unwrap(); + + let snap = catalog.snapshot(); + let a = snap.audio.renditions.values().next().expect("an AAC track"); + assert!(matches!(&a.codec, AudioCodec::AAC(aac) if aac.profile == profile)); + assert_eq!(a.sample_rate, 48_000); + assert_eq!(a.channel_count, 2); + } +} + #[tokio::test(start_paused = true)] async fn import_enhanced_ac3() { let mut out = flv_header(0x04); diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index 4f5e2b3d58b..7d8f2acfb35 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -169,8 +169,7 @@ impl Rendition { Rendition::Video(config) => { Duration::from_secs_f64(1.0 / super::usable_video_framerate(config).unwrap_or(30.0)) } - // ~1024 samples per frame. - Rendition::Audio(config) => Duration::from_secs_f64(1024.0 / config.sample_rate.max(1) as f64), + Rendition::Audio(config) => super::audio_default_frame(config), } } } diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 94914d14a18..a2965f6d2be 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -996,6 +996,16 @@ pub(crate) fn default_video_timescale(config: &VideoConfig) -> u64 { .unwrap_or(90_000) } +/// The fallback duration of an audio frame that states none: ~1024 samples, which SBR doubles +/// along with the output rate the catalog names. +pub(crate) fn audio_default_frame(config: &AudioConfig) -> Duration { + let samples = match &config.codec { + AudioCodec::AAC(aac) if matches!(aac.profile, 5 | 29) => 2048.0, + _ => 1024.0, + }; + Duration::from_secs_f64(samples / config.sample_rate.max(1) as f64) +} + /// A finite catalog framerate with a synthesized scale and duration that fit their MP4 fields. pub(crate) fn usable_video_framerate(config: &VideoConfig) -> Option { config @@ -1144,6 +1154,17 @@ mod tests { config } + #[test] + fn he_aac_default_frame_spans_the_core_frame() { + // 1024 core samples at 24 kHz, named as 2048 at the 48 kHz output. + for profile in [5, 29] { + let config = AudioConfig::new(AudioCodec::AAC(hang::catalog::AAC { profile }), 48_000, 2); + assert_eq!(audio_default_frame(&config), Duration::from_secs_f64(1024.0 / 24_000.0)); + } + let lc = AudioConfig::new(AudioCodec::AAC(hang::catalog::AAC { profile: 2 }), 48_000, 2); + assert_eq!(audio_default_frame(&lc), Duration::from_secs_f64(1024.0 / 48_000.0)); + } + /// The moov an encoded init segment carries, so a test asserts on what a player parses /// rather than on the struct that went in. fn moov(init: &Bytes) -> mp4_atom::Moov { diff --git a/rs/moq-mux/src/container/fmp4/muxer.rs b/rs/moq-mux/src/container/fmp4/muxer.rs index c220b7ddb56..1933754a1e8 100644 --- a/rs/moq-mux/src/container/fmp4/muxer.rs +++ b/rs/moq-mux/src/container/fmp4/muxer.rs @@ -91,8 +91,7 @@ impl Muxer { transform: None, description: config.description.as_ref().filter(|b| !b.is_empty()).cloned(), timescale: moq_net::Timescale::new(catalog_timescale_audio(config)?).map_err(Error::from)?, - // Fallback for a duration-less trailing sample (~1024 samples per frame). - default_frame: Duration::from_secs_f64(1024.0 / config.sample_rate.max(1) as f64), + default_frame: super::audio_default_frame(config), opus: matches!(config.codec, hang::catalog::AudioCodec::Opus), kind: Kind::Audio(config.clone()), }) diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index ae365df55e6..1b53a205e24 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -650,13 +650,23 @@ pub(super) fn build_audio_track_entry(track_number: u64, config: &AudioConfig) - MatroskaSpec::TrackType(2), MatroskaSpec::CodecID(codec_id.to_string()), ]; + // The catalog names the output rate. Matroska puts an SBR core's rate first and the output + // after it, as a separate field. + let sampling_rate = match (&config.codec, &codec_private) { + (AudioCodec::AAC(_), Some(asc)) => crate::codec::aac::in_band(asc)?.sample_rate, + _ => config.sample_rate, + }; if let Some(cp) = codec_private { entry.push(MatroskaSpec::CodecPrivate(cp)); } - entry.push(MatroskaSpec::Audio(Master::Full(vec![ - MatroskaSpec::SamplingFrequency(config.sample_rate as f64), + let mut audio = vec![ + MatroskaSpec::SamplingFrequency(sampling_rate as f64), MatroskaSpec::Channels(config.channel_count as u64), - ]))); + ]; + if sampling_rate != config.sample_rate { + audio.push(MatroskaSpec::OutputSamplingFrequency(config.sample_rate as f64)); + } + entry.push(MatroskaSpec::Audio(Master::Full(audio))); Ok(MatroskaSpec::TrackEntry(Master::Full(entry))) } diff --git a/rs/moq-mux/src/container/mkv/export_test.rs b/rs/moq-mux/src/container/mkv/export_test.rs index 95a7fe72c3d..7d9bd02fd13 100644 --- a/rs/moq-mux/src/container/mkv/export_test.rs +++ b/rs/moq-mux/src/container/mkv/export_test.rs @@ -202,6 +202,45 @@ fn build_flac_audio_track_entry() { crate::codec::flac::Config::parse(&mut private.as_slice()).expect("valid FLAC header"); } +/// The catalog names HE-AAC's output rate; Matroska wants the SBR core's rate as +/// SamplingFrequency and the output as OutputSamplingFrequency. +#[test] +fn build_he_aac_audio_track_entry() { + // fdkaacenc HE-AAC at 48 kHz stereo: SBR over a 24 kHz LC core. + let mut config = hang::catalog::AudioConfig::new(AudioCodec::AAC(hang::catalog::AAC { profile: 5 }), 48_000, 2); + config.description = Some(Bytes::from_static(&[0x2B, 0x11, 0x88, 0x00])); + assert_eq!(audio_rates(&config), (24_000.0, Some(48_000.0))); + + // AAC-LC 48 kHz stereo plays at its own rate. + config.codec = AudioCodec::AAC(hang::catalog::AAC { profile: 2 }); + config.description = Some(Bytes::from_static(&[0x11, 0x90])); + assert_eq!(audio_rates(&config), (48_000.0, None)); +} + +/// The SamplingFrequency and OutputSamplingFrequency of an exported audio track entry. +fn audio_rates(config: &hang::catalog::AudioConfig) -> (f64, Option) { + let entry = super::export::build_audio_track_entry(1, config).expect("build audio entry"); + let MatroskaSpec::TrackEntry(Master::Full(children)) = entry else { + panic!("expected a TrackEntry"); + }; + let audio = children + .into_iter() + .find_map(|c| match c { + MatroskaSpec::Audio(Master::Full(audio)) => Some(audio), + _ => None, + }) + .expect("an Audio element"); + let sampling = audio.iter().find_map(|c| match c { + MatroskaSpec::SamplingFrequency(rate) => Some(*rate), + _ => None, + }); + let output = audio.iter().find_map(|c| match c { + MatroskaSpec::OutputSamplingFrequency(rate) => Some(*rate), + _ => None, + }); + (sampling.expect("SamplingFrequency"), output) +} + /// MP3 (config in band, no codec private) survives an import -> export -> re-import /// round trip as the `A_MPEG/L3` track entry. #[tokio::test(start_paused = true)] From 4ad54e9af661ef6685c5add11ddde788d48f16c4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 11:56:36 -0700 Subject: [PATCH 3/4] test(moq-mux): pin truncated PS refusal alongside SBR Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/codec/aac/mod.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index c48ea913ff8..df6d682adc4 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -629,11 +629,13 @@ mod tests { assert_eq!(cfg.encode(), asc.as_slice(), "encode writes the core back"); } - // A config naming SBR but stopping before its extension rate has no output to name. - assert!(matches!( - Config::parse(&mut [0x2A, 0x10].as_slice()), - Err(Error::IncompleteConfig) - )); + // A config naming SBR or PS but stopping before its extension rate has no output to name. + for truncated in [[0x2A, 0x10], [0xEA, 0x08]] { + assert!(matches!( + Config::parse(&mut truncated.as_slice()), + Err(Error::IncompleteConfig) + )); + } } #[test] From c44292cca01f249628e849a17af8b8a32cdc5c23 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 13:02:32 -0700 Subject: [PATCH 4/4] fix(moq-mux): split the MKV rate only for explicit SBR AAC-LC keeps the catalog rate without parsing its description, so an export that worked before still does, and a mismatched LC description no longer gains an OutputSamplingFrequency. Also pins HE-AACv2 in the MKV entry test and deletes the truncated-SBR quest this PR completes. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/aac-parse-truncated-sbr.md | 21 --------------------- rs/moq-mux/src/container/mkv/export.rs | 16 +++++++++------- rs/moq-mux/src/container/mkv/export_test.rs | 9 +++++++++ 4 files changed, 18 insertions(+), 29 deletions(-) delete mode 100644 quest/m1/aac-parse-truncated-sbr.md diff --git a/quest/m1/README.md b/quest/m1/README.md index ed6d4c8c1b9..faad2712bfe 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -107,7 +107,6 @@ blocks. The quests that gated m0 lines moved under them. - [TS Opus channel codes](/quest/m1/ts-opus-channel-codes.md) - TS Opus with a channel code of 0x81 or above imports with its real head or is refused, never guessed as stereo - [AudioToolbox encode](/quest/m1/audio-encode-audiotoolbox.md) - macOS and iOS encode AAC-LC - [AAC encode refusals](/quest/m1/aac-encode-refusals.md) - `Config::encode` refuses channel counts it cannot name instead of writing stereo -- [AAC parse truncated SBR](/quest/m1/aac-parse-truncated-sbr.md) - `Config::parse` refuses SBR and PS configs cut off before their core - [GStreamer surround Opus](/quest/m1/gst-opus-surround.md) - the moq-gst sink publishes 3 to 8 channel Opus with the OpusHead its caps describe - [TS AAC PCE joins](/quest/m1/ts-aac-pce-join.md) - PCE-described AAC over TS plays after a mid-stream join or resume, and a missing PCE silences only its track - [FFI frame duration default](/quest/m1/ffi-frame-duration-default.md) - the binding audio encoder takes the codec's own frame by default, so `aac()` needs no explicit 0 diff --git a/quest/m1/aac-parse-truncated-sbr.md b/quest/m1/aac-parse-truncated-sbr.md deleted file mode 100644 index 3a6d94fc831..00000000000 --- a/quest/m1/aac-parse-truncated-sbr.md +++ /dev/null @@ -1,21 +0,0 @@ -# [XS] AAC config parse refuses truncated SBR and PS - -## Goal - -`Config::parse` refuses an AudioSpecificConfig that signals SBR or PS -(object type 5 or 29) but ends before its extension rate and core object type, -instead of accepting it as if the core were known. - -## Plan - -- The export path already requires these fields; `parse` stays lenient only - because `Config::encode` writes a two-byte config for those object types. - Once encode refuses or completes them, the leniency has no producer. -- Check the `moq-audio` tests that feed a two-byte HE-AAC config; they should - keep asserting a refusal, just a different one. - -Public API: none beyond a new error case. Wire: none. - -## Required - -- [AAC encode refusals](/quest/m1/aac-encode-refusals.md) - encode stops producing truncated SBR and PS configs diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index 1b53a205e24..c35f4b19e74 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -650,20 +650,22 @@ pub(super) fn build_audio_track_entry(track_number: u64, config: &AudioConfig) - MatroskaSpec::TrackType(2), MatroskaSpec::CodecID(codec_id.to_string()), ]; - // The catalog names the output rate. Matroska puts an SBR core's rate first and the output - // after it, as a separate field. - let sampling_rate = match (&config.codec, &codec_private) { - (AudioCodec::AAC(_), Some(asc)) => crate::codec::aac::in_band(asc)?.sample_rate, - _ => config.sample_rate, + // The catalog names the output rate. Under explicit SBR, Matroska puts the core's rate first + // and the output after it, as a separate field. + let core_rate = match (&config.codec, &codec_private) { + (AudioCodec::AAC(aac), Some(asc)) if matches!(aac.profile, 5 | 29) => { + Some(crate::codec::aac::in_band(asc)?.sample_rate) + } + _ => None, }; if let Some(cp) = codec_private { entry.push(MatroskaSpec::CodecPrivate(cp)); } let mut audio = vec![ - MatroskaSpec::SamplingFrequency(sampling_rate as f64), + MatroskaSpec::SamplingFrequency(core_rate.unwrap_or(config.sample_rate) as f64), MatroskaSpec::Channels(config.channel_count as u64), ]; - if sampling_rate != config.sample_rate { + if core_rate.is_some_and(|core| core != config.sample_rate) { audio.push(MatroskaSpec::OutputSamplingFrequency(config.sample_rate as f64)); } entry.push(MatroskaSpec::Audio(Master::Full(audio))); diff --git a/rs/moq-mux/src/container/mkv/export_test.rs b/rs/moq-mux/src/container/mkv/export_test.rs index 7d9bd02fd13..b5a98692b12 100644 --- a/rs/moq-mux/src/container/mkv/export_test.rs +++ b/rs/moq-mux/src/container/mkv/export_test.rs @@ -211,10 +211,19 @@ fn build_he_aac_audio_track_entry() { config.description = Some(Bytes::from_static(&[0x2B, 0x11, 0x88, 0x00])); assert_eq!(audio_rates(&config), (24_000.0, Some(48_000.0))); + // HE-AACv2: the same rates over a mono PS core. + config.codec = AudioCodec::AAC(hang::catalog::AAC { profile: 29 }); + config.description = Some(Bytes::from_static(&[0xEB, 0x09, 0x88, 0x00])); + assert_eq!(audio_rates(&config), (24_000.0, Some(48_000.0))); + // AAC-LC 48 kHz stereo plays at its own rate. config.codec = AudioCodec::AAC(hang::catalog::AAC { profile: 2 }); config.description = Some(Bytes::from_static(&[0x11, 0x90])); assert_eq!(audio_rates(&config), (48_000.0, None)); + + // AAC-LC keeps the catalog's rate without parsing its description (a reserved rate index here). + config.description = Some(Bytes::from_static(&[0x16, 0x90])); + assert_eq!(audio_rates(&config), (48_000.0, None)); } /// The SamplingFrequency and OutputSamplingFrequency of an exported audio track entry.