diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index ed91418d1ad..7f95bc30833 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -247,6 +247,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}}). @@ -1089,6 +1090,7 @@ This document has no IANA actions. - Described deriving HLS and DASH at the edge from a reference rendition's records. - An edge keeps its derived-format video reference rendition while that rendition stays in the catalog; an audio reference yields once video has a timeline. - Added an optional `epoch` to `.info`: the source route's Epoch, compared on resume so a restarted source fails enrollment. +- 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 620bdfa56b2..75dc9f965b9 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -93,7 +93,6 @@ were done or not worth their cost. - [VideoToolbox presets](/quest/m1/videotoolbox-presets.md) - VideoToolbox honors Balanced and Quality instead of always reporting LowLatency - [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 - [AudioToolbox encode](/quest/m1/audio-encode-audiotoolbox.md) - macOS and iOS encode AAC-LC - [mp4-atom dOps mapping](/quest/m1/mp4-atom-dops-mapping.md) - a released mp4-atom reads and writes any `dOps` channel mapping family and table - [CMAF surround Opus](/quest/m1/cmaf-opus-surround.md) - fMP4 import and export carry an Opus channel mapping table 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 2e65fdb73ce..de7ff717dbe 100644 --- a/rs/moq-audio/src/aac.rs +++ b/rs/moq-audio/src/aac.rs @@ -282,8 +282,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 11019e41e66..511fc2bc5ed 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -697,6 +697,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 8707da1872c..ebd625be652 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -63,8 +63,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, } @@ -76,19 +79,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. @@ -106,46 +117,32 @@ 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. A channel count that does + /// 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. A channel count that does /// not pick a channelConfiguration is refused rather than written as another layout. pub fn encode(&self) -> Result { - // 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); - Ok(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(); - Ok(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); + Ok(Bytes::from(out.bytes)) } } @@ -157,36 +154,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 @@ -214,13 +224,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 { @@ -260,7 +264,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 => { @@ -282,6 +286,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; @@ -613,9 +622,34 @@ 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().unwrap(), asc.as_slice(), "encode writes the core back"); + } + + // 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] fn refuses_bad_program_config_elements() { let asc = pce_asc(2, &[], &[], &[], 0); @@ -710,9 +744,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 c3c3da49559..e3fe763d023 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 3a03f9297e0..a24ceba9fe0 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -175,8 +175,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 48843bceaff..586e2a6a5d3 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -1077,6 +1077,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 @@ -1225,6 +1235,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 8a67a5edee9..0b0aaa9e574 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 1327f379697..ba8a8d2f3ad 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -650,13 +650,25 @@ 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. 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)); } - entry.push(MatroskaSpec::Audio(Master::Full(vec![ - MatroskaSpec::SamplingFrequency(config.sample_rate as f64), + let mut audio = vec![ + MatroskaSpec::SamplingFrequency(core_rate.unwrap_or(config.sample_rate) as f64), MatroskaSpec::Channels(config.channel_count as u64), - ]))); + ]; + 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))); 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 e0b9d5d1057..9e29297e98b 100644 --- a/rs/moq-mux/src/container/mkv/export_test.rs +++ b/rs/moq-mux/src/container/mkv/export_test.rs @@ -202,6 +202,54 @@ 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))); + + // 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. +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)]