From 8bb5dbb81e8651072f7e19220342225f8bd2d7f1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:52:57 -0400 Subject: [PATCH] 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. --- Cargo.lock | 1 + crates/media/Cargo.toml | 1 + crates/media/src/downmix.rs | 123 +++++++++++++++++++++++-------- crates/media/src/pool.rs | 77 ++++++++++++------- crates/media/src/probe.rs | 11 +++ crates/media/tests/it/downmix.rs | 72 ++++++++++++++---- 6 files changed, 211 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7b774557..627da7cf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1632,6 +1632,7 @@ dependencies = [ "effectcraft-svg", "effectcraft-time", "exr", + "filmcraft-aac", "filmcraft-codecs", "filmcraft-color", "filmcraft-frame", diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 923a28afa..d3f815bba 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -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 } diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 1ad9721ba..745f28065 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -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 { 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 { - 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], 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 { + 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], order: Order, out: &mut [f32]) { + mix(channels, &decoded_gains(channels.len(), order), out); } fn mix(channels: &[Vec], 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 { + (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); } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ed89bf7e8..193804447 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -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, 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>>; +/// 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>>; + +/// 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, 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, String> { let path: Arc = 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) -> Option { - self.opened(path).map(|(src, _)| src) - } - - /// [`Self::source`] and the turn the movie's picture is shown with. - fn opened(&self, path: &Arc) -> 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) -> Option { 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, 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, 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) } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 9a7ebfa1d..435f9ce57 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -259,6 +259,17 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> 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> { + 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 diff --git a/crates/media/tests/it/downmix.rs b/crates/media/tests/it/downmix.rs index e2c95c251..c139676d6 100644 --- a/crates/media/tests/it/downmix.rs +++ b/crates/media/tests/it/downmix.rs @@ -164,24 +164,66 @@ fn each_channel_reaches_stereo_at_its_bs775_gain() { for c in cases() { let freqs: Vec = 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 = 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, 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 = 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() {