mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Media: downmix AAC in the order its channel configuration decodes in (#1701)
FilmCraft reorders AAC configurations 3 to 6, 11 and 12 to WAVE/SMPTE order and leaves configuration 7 and program config elements as coded, so the channel count alone can't say which order arrives: ffmpeg writes 7.1 as configuration 7 (C L R first) and 6.1 as a PCE it fills in its own order (L R C LFE Cs Ls Rs), while FilmCraft's encoder writes a centre-first PCE. The order is now read from an MP4/MOV's AudioSpecificConfig when the source opens; other containers keep a channel-count guess. The ffmpeg oracle test gains configuration 12 (ffmpeg's 7.1 relabelled) and skips PCE files ffmpeg 4.2 marks as stereo, which FilmCraft reads as two channels.
This commit is contained in:
Generated
+1
@@ -1632,6 +1632,7 @@ dependencies = [
|
||||
"effectcraft-svg",
|
||||
"effectcraft-time",
|
||||
"exr",
|
||||
"filmcraft-aac",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
||||
@@ -21,6 +21,7 @@ effectcraft-pdf = { workspace = true }
|
||||
effectcraft-model = { workspace = true }
|
||||
filmcraft-codecs = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
filmcraft-aac = { workspace = true }
|
||||
filmcraft-isobmff = { workspace = true }
|
||||
filmcraft-frame = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
|
||||
+91
-32
@@ -12,9 +12,10 @@
|
||||
//!
|
||||
//! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM,
|
||||
//! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs,
|
||||
//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. FilmCraft's AAC-LC
|
||||
//! decoder instead delivers AAC's own order (C L R first, LFE last), so AAC gets its own tables.
|
||||
//! More than 8 channels carry no known layout: only the first two (front L/R) are used.
|
||||
//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. AAC arrives in either
|
||||
//! that order or AAC's own (C L R first, LFE last) depending on its channel configuration
|
||||
//! ([`aac_order`]). More than 8 channels carry no known layout: only the first two (front L/R)
|
||||
//! are used.
|
||||
|
||||
use std::f32::consts::FRAC_1_SQRT_2;
|
||||
|
||||
@@ -64,28 +65,59 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec<Pair> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The gains for AAC as FilmCraft's decoder hands it over: channel configurations 3 to 6 (3 to 6
|
||||
/// channels) and 12 (7.1, 8 channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs,
|
||||
/// L R C LFE Lb Rb Ls Rs), everything else (mono, stereo, program-config layouts such as the 6.1
|
||||
/// ffmpeg writes) in AAC's element order. Configuration 11 (6.1) and 7 (7.1 with front wides)
|
||||
/// can't be told from those by the channel count and are read in the order of the commoner one.
|
||||
pub fn decoded_aac_gains(count: usize) -> Vec<Pair> {
|
||||
match count {
|
||||
// C L R Cs, reordered to L R C Cs: the same gains, permuted.
|
||||
4 => {
|
||||
let g = stereo_gains(4, true);
|
||||
[1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect()
|
||||
}
|
||||
3 | 5 | 6 | 8 => stereo_gains(count, false),
|
||||
_ => stereo_gains(count, true),
|
||||
/// The order a source's decoded channels arrive in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Order {
|
||||
/// WAVE / SMPTE by channel count: PCM, FLAC, AC-3, Opus and the rest.
|
||||
#[default]
|
||||
Wav,
|
||||
/// AAC that FilmCraft reorders to WAVE / SMPTE (configurations 3 to 6, 11 and 12; 4 becomes
|
||||
/// L R C Cs) or that ffmpeg wrote in it (a program config element listing the front pair
|
||||
/// first: ffmpeg fills a PCE's elements in its own channel order and reads them back so).
|
||||
AacWav,
|
||||
/// AAC in its element order (C L R … LFE), which FilmCraft keeps: configuration 7 and a PCE
|
||||
/// listing the centre first.
|
||||
AacElements,
|
||||
/// AAC whose configuration the container doesn't show us (only MP4 / MOV headers are read):
|
||||
/// as [`Order::AacWav`] up to 7 channels (configurations 3 to 6, 11 and ffmpeg's 6.1 PCE),
|
||||
/// as [`Order::AacElements`] above (configuration 7, ffmpeg's 7.1).
|
||||
Aac,
|
||||
}
|
||||
|
||||
/// How FilmCraft's decoder hands over the AAC described by the AudioSpecificConfig `asc`.
|
||||
pub fn aac_order(asc: &[u8]) -> Order {
|
||||
let Ok(asc) = filmcraft_aac::AudioSpecificConfig::parse(asc) else { return Order::Aac };
|
||||
match (asc.channel_config, asc.pce) {
|
||||
(3..=6 | 11 | 12, _) => Order::AacWav,
|
||||
(0, Some(pce)) if pce.front.first().is_some_and(|&(cpe, _)| cpe) => Order::AacWav,
|
||||
(0, None) => Order::Aac,
|
||||
_ => Order::AacElements,
|
||||
}
|
||||
}
|
||||
|
||||
/// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a
|
||||
/// channel is too short for count as silence, extra frames in `out` stay as they are.
|
||||
pub fn downmix_into(channels: &[Vec<f32>], aac: bool, out: &mut [f32]) {
|
||||
let gains = if aac { decoded_aac_gains(channels.len()) } else { stereo_gains(channels.len(), false) };
|
||||
mix(channels, &gains, out);
|
||||
/// The gains for `count` channels arriving in `order`.
|
||||
pub fn decoded_gains(count: usize, order: Order) -> Vec<Pair> {
|
||||
let order = match order {
|
||||
Order::Aac if (3..=7).contains(&count) => Order::AacWav,
|
||||
Order::Aac => Order::AacElements,
|
||||
o => o,
|
||||
};
|
||||
match (order, count) {
|
||||
// C L R Cs, reordered to L R C Cs: the same gains, permuted.
|
||||
(Order::AacWav, 4) => {
|
||||
let g = stereo_gains(4, true);
|
||||
[1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect()
|
||||
}
|
||||
(Order::AacElements, _) => stereo_gains(count, true),
|
||||
_ => stereo_gains(count, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downmix planar `channels` arriving in `order` to interleaved stereo in `out` (`out.len() / 2`
|
||||
/// frames); frames a channel is too short for count as silence, extra frames in `out` stay as
|
||||
/// they are.
|
||||
pub fn downmix_into(channels: &[Vec<f32>], order: Order, out: &mut [f32]) {
|
||||
mix(channels, &decoded_gains(channels.len(), order), out);
|
||||
}
|
||||
|
||||
fn mix(channels: &[Vec<f32>], gains: &[Pair], out: &mut [f32]) {
|
||||
@@ -119,19 +151,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// AudioSpecificConfigs as ffmpeg 8.1 and FilmCraft's encoder write them (48 kHz AAC-LC).
|
||||
const PCE_FFMPEG_61: &str = "118004c848002000c4400d4c61766336322e32382e31303256e500";
|
||||
const PCE_FILMCRAFT_61: &str = "118004c845000108840000";
|
||||
|
||||
fn asc(hex: &str) -> Vec<u8> {
|
||||
(0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_aac_is_in_wav_order_for_three_to_six_and_eight_channels() {
|
||||
// FilmCraft hands AAC 3.0 to 5.1 and 7.1 over in the order of the plain layouts.
|
||||
for n in [3, 5, 6, 8] {
|
||||
assert_eq!(decoded_aac_gains(n), stereo_gains(n, false), "{n} channels");
|
||||
fn aac_order_follows_the_channel_configuration() {
|
||||
// AAC-LC at 48 kHz with channel configuration n
|
||||
let config = |n: u8| aac_order(&[0x11, 0x80 | n << 3]);
|
||||
for n in [3, 4, 5, 6, 11, 12] {
|
||||
assert_eq!(config(n), Order::AacWav, "configuration {n}");
|
||||
}
|
||||
let g = decoded_aac_gains(4);
|
||||
for n in [1, 2, 7] {
|
||||
assert_eq!(config(n), Order::AacElements, "configuration {n}");
|
||||
}
|
||||
assert_eq!(aac_order(&asc(PCE_FFMPEG_61)), Order::AacWav, "ffmpeg's 6.1 PCE lists L R first");
|
||||
assert_eq!(aac_order(&asc(PCE_FILMCRAFT_61)), Order::AacElements, "a PCE listing the centre first");
|
||||
assert_eq!(aac_order(&[]), Order::Aac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_aac_gains_follow_the_order() {
|
||||
for n in [3, 5, 6, 7, 8] {
|
||||
assert_eq!(decoded_gains(n, Order::AacWav), stereo_gains(n, false), "{n} channels");
|
||||
assert_eq!(decoded_gains(n, Order::AacElements), stereo_gains(n, true), "{n} channels");
|
||||
}
|
||||
let g = decoded_gains(4, Order::AacWav);
|
||||
assert_eq!(g.len(), 4);
|
||||
assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first");
|
||||
assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides");
|
||||
assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre");
|
||||
for n in [1, 2, 7, 9] {
|
||||
assert_eq!(decoded_aac_gains(n), stereo_gains(n, true), "{n} channels");
|
||||
assert_eq!(decoded_gains(4, Order::Wav), stereo_gains(4, false), "PCM 4 channels are quad");
|
||||
for n in [3, 4, 5, 6, 7] {
|
||||
assert_eq!(decoded_gains(n, Order::Aac), decoded_gains(n, Order::AacWav), "{n} channels");
|
||||
}
|
||||
for n in [1, 2, 8, 9] {
|
||||
assert_eq!(decoded_gains(n, Order::Aac), stereo_gains(n, true), "{n} channels");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,10 +220,10 @@ mod tests {
|
||||
#[test]
|
||||
fn ragged_and_empty_input_never_panics() {
|
||||
let mut out = vec![0.0; 8];
|
||||
downmix_into(&[], false, &mut out);
|
||||
downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], true, &mut out);
|
||||
downmix_into(&[], Order::Wav, &mut out);
|
||||
downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], Order::Aac, &mut out);
|
||||
assert!(out.iter().all(|v| v.is_finite()));
|
||||
let mut none: [f32; 0] = [];
|
||||
downmix_into(&vec![vec![1.0; 3]; 7], false, &mut none);
|
||||
downmix_into(&vec![vec![1.0; 3]; 7], Order::Wav, &mut none);
|
||||
}
|
||||
}
|
||||
|
||||
+50
-27
@@ -12,6 +12,7 @@ use filmcraft_media::{FrameRequest, SharedSource};
|
||||
use image::metadata::Orientation;
|
||||
|
||||
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned};
|
||||
use crate::downmix::Order;
|
||||
use crate::gif_anim::{self, Anim};
|
||||
use crate::{MediaError, Result};
|
||||
|
||||
@@ -178,10 +179,37 @@ struct CacheState {
|
||||
last: HashMap<Arc<str>, i64>,
|
||||
}
|
||||
|
||||
/// A movie/audio source by path and the turn its picture is shown with
|
||||
/// ([`crate::probe::video_turn`]), opened by the first caller while the others wait for it
|
||||
/// (`None`: failed to open; not retried until `forget`).
|
||||
type SourceSlot = Arc<OnceLock<Option<(SharedSource, Orientation)>>>;
|
||||
/// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait
|
||||
/// for it (`None`: failed to open; not retried until `forget`).
|
||||
type SourceSlot = Arc<OnceLock<Option<Opened>>>;
|
||||
|
||||
/// An opened movie/audio source, the turn its picture is shown with and the order its decoded
|
||||
/// audio channels arrive in.
|
||||
#[derive(Clone)]
|
||||
struct Opened {
|
||||
src: SharedSource,
|
||||
turn: Orientation,
|
||||
order: Order,
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
/// `src` with what its MP4/MOV `header` (ftyp and moov) says, if it has one.
|
||||
fn new(src: SharedSource, header: Option<&[u8]>) -> Opened {
|
||||
let turn = header.map_or(Orientation::NoTransforms, crate::probe::video_turn);
|
||||
let order = channel_order(&src, header.and_then(crate::probe::aac_config).as_deref());
|
||||
Opened { src, turn, order }
|
||||
}
|
||||
}
|
||||
|
||||
/// The order `src`'s audio channels decode in, AAC's decided by its AudioSpecificConfig `asc`
|
||||
/// when the container's header was read.
|
||||
fn channel_order(src: &SharedSource, asc: Option<&[u8]>) -> Order {
|
||||
match (src.info().audio().is_some_and(|a| a.codec.starts_with("AAC")), asc) {
|
||||
(false, _) => Order::Wav,
|
||||
(true, Some(asc)) => crate::downmix::aac_order(asc),
|
||||
(true, None) => Order::Aac,
|
||||
}
|
||||
}
|
||||
|
||||
/// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load).
|
||||
type GifSlot = Arc<OnceLock<std::result::Result<Arc<Anim>, String>>>;
|
||||
@@ -514,7 +542,7 @@ impl MediaPool {
|
||||
/// [`MediaPool::decode_audio`], failing when the source can't be opened or read.
|
||||
fn try_decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> std::result::Result<Vec<f32>, String> {
|
||||
let path: Arc<str> = footage.path.as_str().into();
|
||||
let src = self.inner.source(&path).ok_or("the file can't be opened")?;
|
||||
let Opened { src, order, .. } = self.inner.opened(&path).ok_or("the file can't be opened")?;
|
||||
let s0 = start.to_units_floor(rate as i64);
|
||||
let native = src.info().audio().map_or(rate, |a| a.sample_rate);
|
||||
// The header's rate scales the samples read: 0xFFFFFFFF Hz made one conform chunk about
|
||||
@@ -523,14 +551,14 @@ impl MediaPool {
|
||||
return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz"));
|
||||
}
|
||||
if native == 0 || rate == 0 || native == rate {
|
||||
return read_stereo(&src, s0, frames, rate);
|
||||
return read_stereo(&src, order, s0, frames, rate);
|
||||
}
|
||||
// Output sample `n` sits at source position n × native / rate.
|
||||
let (native_i, rate_i) = (i128::from(native), i128::from(rate));
|
||||
let first = (i128::from(s0) * native_i).div_euclid(rate_i);
|
||||
let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1;
|
||||
let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) };
|
||||
let buf = read_stereo(&src, first64, count, native)?;
|
||||
let buf = read_stereo(&src, order, first64, count, native)?;
|
||||
let mut out = Vec::with_capacity(frames * 2);
|
||||
for k in 0..frames as i128 {
|
||||
let pos = (i128::from(s0) + k) * native_i;
|
||||
@@ -593,17 +621,12 @@ impl Inner {
|
||||
crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// The opened movie/audio source for `path`. The first caller opens it, outside the map's
|
||||
/// lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF files are
|
||||
/// decoded whole); callers asking for the same file meanwhile wait for that instead of opening
|
||||
/// it again: a preview's frame workers, audio feeder, conform and waveform threads each
|
||||
/// decoded a long FLAC in full at once (#408).
|
||||
fn source(&self, path: &Arc<str>) -> Option<SharedSource> {
|
||||
self.opened(path).map(|(src, _)| src)
|
||||
}
|
||||
|
||||
/// [`Self::source`] and the turn the movie's picture is shown with.
|
||||
fn opened(&self, path: &Arc<str>) -> Option<(SharedSource, Orientation)> {
|
||||
/// The opened movie/audio source for `path` ([`Opened`]). The first caller opens it, outside
|
||||
/// the map's lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF
|
||||
/// files are decoded whole); callers asking for the same file meanwhile wait for that instead
|
||||
/// of opening it again: a preview's frame workers, audio feeder, conform and waveform threads
|
||||
/// each decoded a long FLAC in full at once (#408).
|
||||
fn opened(&self, path: &Arc<str>) -> Option<Opened> {
|
||||
let slot = lock(&self.sources).entry(path.clone()).or_default().clone();
|
||||
slot.get_or_init(|| {
|
||||
self.opened.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -613,14 +636,15 @@ impl Inner {
|
||||
let file = std::path::Path::new(&**path);
|
||||
let in_place = if in_memory { None } else { crate::stream::open_source(file) };
|
||||
let in_place = in_place.map(|src| {
|
||||
let turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h));
|
||||
src.map(|src| (src, turn))
|
||||
let header = crate::stream::movie_header(file);
|
||||
src.map(|src| Opened::new(src, header.as_deref()))
|
||||
});
|
||||
let opened = in_place.unwrap_or_else(|| {
|
||||
self.read(path).and_then(|bytes| {
|
||||
let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let turn = crate::probe::video_turn(&bytes);
|
||||
filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from)
|
||||
let (turn, asc) = (crate::probe::video_turn(&bytes), crate::probe::aac_config(&bytes));
|
||||
let src = filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from)?;
|
||||
Ok(Opened { order: channel_order(&src, asc.as_deref()), src, turn })
|
||||
})
|
||||
});
|
||||
match opened {
|
||||
@@ -854,7 +878,7 @@ impl Inner {
|
||||
Ok(dynamic_to_image(frame, op))
|
||||
}
|
||||
Some(mt) => {
|
||||
let (src, turn) = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?;
|
||||
let Opened { src, turn, .. } = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?;
|
||||
let vf = src
|
||||
.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0)))
|
||||
.map_err(MediaError::from)
|
||||
@@ -953,8 +977,8 @@ impl FootageSource for MediaPool {
|
||||
|
||||
/// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which
|
||||
/// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both
|
||||
/// channels, multichannel sources are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails.
|
||||
fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result<Vec<f32>, String> {
|
||||
/// channels, multichannel sources (channels in `order`) are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails.
|
||||
fn read_stereo(src: &SharedSource, order: Order, s0: i64, frames: usize, rate: u32) -> std::result::Result<Vec<f32>, String> {
|
||||
let mut out = vec![0.0; frames * 2];
|
||||
let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0);
|
||||
if skip >= frames {
|
||||
@@ -962,8 +986,7 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re
|
||||
}
|
||||
let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?;
|
||||
let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) };
|
||||
let aac = src.info().audio().is_some_and(|a| a.codec.starts_with("AAC"));
|
||||
crate::downmix::downmix_into(&buf.channels, aac, dst);
|
||||
crate::downmix::downmix_into(&buf.channels, order, dst);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -259,6 +259,17 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option<image::ImageFormat>) -> im
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The AudioSpecificConfig of an MP4/MOV movie's first audio track (the one FilmCraft plays), if
|
||||
/// that is AAC.
|
||||
pub(crate) fn aac_config(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let file = filmcraft_isobmff::open(bytes).ok()?;
|
||||
let track = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Audio)?;
|
||||
match &track.entries.first()?.codec {
|
||||
filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and
|
||||
/// says so in its video track's header): the quarter turn nearest the track's rotation. Only the
|
||||
/// rotation counts; any mirroring, scaling or offset in the header is ignored, and other
|
||||
|
||||
@@ -164,24 +164,66 @@ fn each_channel_reaches_stereo_at_its_bs775_gain() {
|
||||
for c in cases() {
|
||||
let freqs: Vec<u32> = FREQS.iter().copied().take(c.names.len()).collect();
|
||||
let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue };
|
||||
let s = ours(&p);
|
||||
let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3)
|
||||
for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() {
|
||||
for (side, gain) in g.iter().enumerate() {
|
||||
let got = amp(&s, side, freqs[i]);
|
||||
let want = sine * gain;
|
||||
assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]);
|
||||
}
|
||||
}
|
||||
let theirs = ffmpeg_ac2(&p);
|
||||
let (a, b) = (rms(&s), rms(&theirs));
|
||||
let db = 20.0 * (a / b).log10();
|
||||
assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file);
|
||||
let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10();
|
||||
assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file);
|
||||
check(&c, &p);
|
||||
}
|
||||
}
|
||||
|
||||
/// AAC 7.1 in channel configuration 12 (back surrounds), which FilmCraft reorders where it
|
||||
/// leaves ffmpeg's configuration 7 as coded: ffmpeg's 7.1 with its configuration rewritten (both
|
||||
/// code the same elements).
|
||||
#[test]
|
||||
fn seven_one_aac_in_configuration_12() {
|
||||
let c = cases().into_iter().find(|c| c.file == "dm71.m4a").expect("the 7.1 AAC case");
|
||||
let freqs: Vec<u32> = FREQS.iter().copied().take(c.names.len()).collect();
|
||||
let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { return };
|
||||
let mut bytes = std::fs::read(&p).expect("read");
|
||||
let (asc, _) = aac_entry(&bytes).expect("an AAC track");
|
||||
assert_eq!(asc.get(..2), Some(&[0x11, 0xB8][..]), "AAC-LC 48 kHz, configuration 7");
|
||||
let at = bytes.windows(asc.len()).position(|w| w == asc).expect("the configuration in the file");
|
||||
bytes[at + 1] = 0xE0;
|
||||
let p12 = fixtures().join("dm71c12.m4a");
|
||||
std::fs::write(&p12, bytes).expect("write");
|
||||
check(&Case { file: "dm71c12.m4a", ..c }, &p12);
|
||||
}
|
||||
|
||||
/// The AudioSpecificConfig and sample entry channel count of an MP4's first AAC track.
|
||||
fn aac_entry(bytes: &[u8]) -> Option<(Vec<u8>, u32)> {
|
||||
let file = filmcraft_isobmff::open(bytes).ok()?;
|
||||
file.tracks.iter().flat_map(|t| &t.entries).find_map(|e| match &e.codec {
|
||||
filmcraft_isobmff::CodecConfig::Aac(a) => Some((a.asc.clone(), e.audio.as_ref()?.channels)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn check(c: &Case, p: &Path) {
|
||||
let pce_as_stereo = aac_entry(&std::fs::read(p).expect("read")).is_some_and(|(asc, entry)| {
|
||||
let config = filmcraft_aac::AudioSpecificConfig::parse(&asc).map(|a| a.channel_config);
|
||||
config.ok() == Some(0) && entry == 2
|
||||
});
|
||||
if pce_as_stereo && c.names.len() > 2 {
|
||||
// ffmpeg 4.2 writes 2 in the sample entry of an AAC PCE layout (8.1 the real count), and
|
||||
// that is all FilmCraft decodes.
|
||||
eprintln!("{}: FilmCraft reads 2 of its {} channels: skipping", c.file, c.names.len());
|
||||
return;
|
||||
}
|
||||
let s = ours(p);
|
||||
let freqs: Vec<u32> = FREQS.iter().copied().take(c.names.len()).collect();
|
||||
let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3)
|
||||
for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() {
|
||||
for (side, gain) in g.iter().enumerate() {
|
||||
let got = amp(&s, side, freqs[i]);
|
||||
let want = sine * gain;
|
||||
assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]);
|
||||
}
|
||||
}
|
||||
let theirs = ffmpeg_ac2(p);
|
||||
let (a, b) = (rms(&s), rms(&theirs));
|
||||
let db = 20.0 * (a / b).log10();
|
||||
assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file);
|
||||
let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10();
|
||||
assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file);
|
||||
}
|
||||
|
||||
/// Full-scale in every channel must not clip.
|
||||
#[test]
|
||||
fn loud_surround_does_not_clip() {
|
||||
|
||||
Reference in New Issue
Block a user