diff --git a/crates/engine/src/essential_sound.rs b/crates/engine/src/essential_sound.rs index cd05fca8..58d7556a 100644 --- a/crates/engine/src/essential_sound.rs +++ b/crates/engine/src/essential_sound.rs @@ -12,7 +12,7 @@ //! | `essentialSound.setType` / `clearType` | assign / clear the audio type (clearing removes its effects and gain) | //! | `essentialSound.set` | one setting by dotted key (`repair.noise.amount`), or several (`values`) | //! | `essentialSound.applyPreset` / `savePreset` / `deletePreset` | built-in and user presets | -//! | `essentialSound.autoMatch` | measure integrated loudness (BS.1770) and set the match gain to the target | +//! | `essentialSound.autoMatch` | measure integrated loudness (BS.1770, mono clips as one channel) and set the match gain to the target | //! | `essentialSound.generateDucking` | Volume keyframes that duck Music / Ambience under the trigger types | use serde_json::{Value, json}; @@ -262,15 +262,26 @@ fn delete_preset(s: &mut Session, p: &Value) -> Result { /// Integrated loudness (LUFS) of a clip's own signal (clip gain + clip effects) over its whole /// timeline range, or `None` when it is silent / has no audio. +/// +/// The clip is measured with the channels it has: a stereo (or wider) source as the two channels +/// it plays, a mono source or a mono pick as one channel (its two identical sides folded to one), +/// the way BS.1770 and ffmpeg's `ebur128` measure a mono programme. Metering the dual-mono pair the +/// clip plays would read 3 LU louder than the file and match it 3 dB too quietly (#296). pub fn clip_loudness(item: &TrackItem, sr: u32, sources: &dyn SourceProvider) -> Option { let a0 = item.start.to_units_floor(sr as i64); let a1 = item.end().to_units_floor(sr as i64); - let mut meter = LoudnessMeter::new(sr as f64, 2); + let mono = filmcraft_render::audio::clip_is_mono(item, sources)?; + let mut meter = LoudnessMeter::new(sr as f64, if mono { 1 } else { 2 }); let mut pos = a0; while pos < a1 { let n = (a1 - pos).min(sr as i64) as usize; let [l, r] = filmcraft_render::audio::clip_signal(item, pos, n, sr, sources)?; - meter.process(&[&l, &r]); + if mono { + let m: Vec = l.iter().zip(&r).map(|(a, b)| 0.5 * (a + b)).collect(); + meter.process(&[&m]); + } else { + meter.process(&[&l, &r]); + } pos += n as i64; } Some(meter.integrated()).filter(|v| v.is_finite()) diff --git a/crates/engine/src/essential_sound_tests.rs b/crates/engine/src/essential_sound_tests.rs index 2474972a..e7b5374e 100644 --- a/crates/engine/src/essential_sound_tests.rs +++ b/crates/engine/src/essential_sound_tests.rs @@ -265,6 +265,41 @@ fn auto_match_hits_the_target_loudness() { } } +/// #296: a mono file measured ~3 LU too loud. The same speech imported as a 1-channel WAV and as +/// a dual-mono stereo WAV must measure 3.01 LU apart (the stereo pair carries twice the energy), +/// the mono clip must agree with a one-channel BS.1770 meter over the file (what ffmpeg's +/// `ebur128` reports), and a mono pick (Modify ▸ Audio Channels) of the stereo file counts as mono. +#[test] +fn auto_match_measures_a_mono_clip_as_one_channel() { + let sig = speech(12.0, 0.3, |t| (t % 3.0) < 2.2); + let (mut s, c) = session_with(&[(sig.clone(), 0.0)]); + let stereo = c[0]; + let mono_wav: Arc<[u8]> = filmcraft_media::wav::write_wav16(&sig, 1, SR).into(); + let mono_item = crate::commands::import_bytes(&mut s, "/mono.wav", mono_wav, None).unwrap(); + let r = s.execute("timeline.place", json!({"item": mono_item.0, "audioTrack": "A2", "seconds": 0.0})).unwrap(); + let mono = ClipId(r["clips"][0].as_u64().unwrap()); + for c in [stereo, mono] { + s.execute("essentialSound.setType", json!({"clips": [c.0], "type": "dialogue"})).unwrap(); + } + let measured = |s: &mut Session, c: ClipId| { + let r = s.execute("essentialSound.autoMatch", json!({"clips": [c.0], "target": -23.0})).unwrap(); + r["clips"][0]["measuredLufs"].as_f64().unwrap() + }; + let (two, one) = (measured(&mut s, stereo), measured(&mut s, mono)); + let mut file = LoudnessMeter::new(SR as f64, 1); + file.process(&[&sig]); + let want = file.integrated(); + println!("stereo (dual mono) {two:.3} LUFS, mono {one:.3} LUFS, mono file {want:.3} LUFS"); + assert!((one - want).abs() <= 0.1, "mono clip {one} LUFS, the file reads {want} LUFS"); + assert!((two - one - 3.0103).abs() <= 0.1, "dual mono {two} LUFS should read 3.01 LU above mono {one} LUFS"); + let st = item(&s, mono).essential.unwrap(); + assert!((st.loudness.gain_db - (-23.0 - want)).abs() <= 0.1, "match gain {} dB for {want} LUFS", st.loudness.gain_db); + // one picked channel of the stereo file plays on both sides: measured as mono too + s.execute("clip.audioChannels", json!({"clips": [stereo.0], "channels": [0]})).unwrap(); + let picked = measured(&mut s, stereo); + assert!((picked - want).abs() <= 0.1, "mono pick {picked} LUFS, the file reads {want} LUFS"); +} + #[test] fn ducking_generates_volume_keyframes_under_dialogue() { // dialogue in [2, 4] and [6, 7.5] s; music for 10 s diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 45438fee..20cf7355 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -338,6 +338,18 @@ pub fn clip_signal(item: &TrackItem, start: i64, frames: usize, sr: u32, sources Some(out) } +/// Whether a clip plays one source channel on both sides: a mono source, or one picked channel +/// (Modify ▸ Audio Channels, Breakout to Mono). Loudness Auto-Match measures such a clip as the one +/// channel it is, as BS.1770 (and ffmpeg's `ebur128`) measure a mono programme, not as the +/// dual-mono pair [`clip_signal`] plays, which reads 3 LU louder. `None` when the clip has no +/// audio source. +pub fn clip_is_mono(item: &TrackItem, sources: &dyn SourceProvider) -> Option { + let src = sources.source(item.item)?; + let available = src.info().audio.as_ref()?.channels as usize; + let (left, right) = source_pair(item, available); + Some(left == right) +} + /// The clip's audio (after speed/reverse) for timeline samples `[x0, x0 + len)` as `w` channels /// (2: stereo, mono sources on both sides; 6: 5.1), with silence outside the clip. fn raw_channels(item: &TrackItem, src: SourceAudio, x0: i64, len: usize, sr: u32, w: usize) -> Vec> { diff --git a/docs/architecture.md b/docs/architecture.md index 3541e9cd..ede9d5e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -450,7 +450,7 @@ Clip ▸ Remix ▸ Enable Remix / Remix Properties… / Revert Remix (`clip.remi ```text clip.essential (type + settings) ──apply()──► clip effects marked `essential`, clip gain, Volume, Panner -essentialSound.autoMatch BS.1770 integrated loudness of render::audio::clip_signal → match gain (clip gain) +essentialSound.autoMatch BS.1770 integrated loudness of render::audio::clip_signal (mono clips as one channel) → match gain (clip gain) essentialSound.generateDucking trigger clips' summed level (10 ms hops) → activity regions → Volume keyframes ``` diff --git a/docs/testing.md b/docs/testing.md index 904f092d..bb57a849 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -188,10 +188,12 @@ switches, a slider drag as one undo step, section bypass, Auto-Match, ducking, B Essential Sound engine tests (`crates/engine/src/essential_sound_tests.rs`) build projects from generated speech-like and tonal WAVs: Auto-Match lands within ±0.5 LU of the target (measured: 0.000 LU, -with and without a repair/clarity chain); ducking on a dialogue + music project gives keyframes within -60 ms of the expected times (measured 20 ms) and −15.00 dB in the mix; each repair stage improves its -metric through the render path (hum −39 dB, rumble −22 dB, noise floor −13 dB, sibilance −19 dB, -reverb tail −10 dB); the mix is bit-identical however requests are cut and the WAV export equals it. +with and without a repair/clarity chain) and measures a mono clip as one channel (3.01 LU below the +same signal as dual mono, within 0.1 LU of a one-channel meter over the file); ducking on a dialogue + +music project gives keyframes within 60 ms of the expected times (measured 20 ms) and −15.00 dB in the +mix; each repair stage improves its metric through the render path (hum −39 dB, rumble −22 dB, noise +floor −13 dB, sibilance −19 dB, reverb tail −10 dB); the mix is bit-identical however requests are cut +and the WAV export equals it. `perf_full_dialogue_chain_realtime_factor` (ignored; run with `--release`) prints the realtime factor of all nine Dialogue effects on one clip (22× on one core).