Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions drafts/draft-lcurley-moq-hang.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}}).

Expand Down Expand Up @@ -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"}
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 0 additions & 29 deletions quest/m1/he-aac-catalog-output.md

This file was deleted.

4 changes: 2 additions & 2 deletions rs/moq-audio/src/aac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(_))));
}
Expand Down
17 changes: 17 additions & 0 deletions rs/moq-mux/src/catalog/msf/consumer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
166 changes: 99 additions & 67 deletions rs/moq-mux/src/codec/aac/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ pub type Result<T> = std::result::Result<T, Error>;
/// 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,
}

Expand All @@ -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<T: Buf>(buf: &mut T) -> Result<Self> {
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.
Expand All @@ -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<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);
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))
}
}

Expand All @@ -157,36 +154,49 @@ fn read_header<T: Buf>(reader: &mut BitReader<T>) -> 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<T: Buf>(reader: &mut BitReader<T>, object_type: u8) -> Result<u8> {
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<T: Buf>(reader: &mut BitReader<T>, 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<T: Buf>(reader: &mut BitReader<T>) -> Result<u32> {
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
Expand Down Expand Up @@ -214,13 +224,7 @@ fn read_general_audio<T: Buf>(reader: &mut BitReader<T>, core: u8) -> Result<()>
pub(crate) fn in_band_config(profile: u8, sample_rate: u32, channel_config: u8, block: &mut &[u8]) -> Result<Bytes> {
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 {
Expand Down Expand Up @@ -260,7 +264,7 @@ pub(crate) fn in_band(asc: &[u8]) -> Result<InBand> {
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 => {
Expand All @@ -282,6 +286,11 @@ pub(crate) fn in_band(asc: &[u8]) -> Result<InBand> {
})
}

/// 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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions rs/moq-mux/src/container/flv/import_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 1 addition & 2 deletions rs/moq-mux/src/container/fmp4/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
}
Expand Down
Loading
Loading