#296: Auto-Match measures a mono clip as one channel

Loudness Auto-Match metered every clip as the stereo pair clip_signal
plays, so a mono source (or a mono pick from Modify > Audio Channels /
Breakout to Mono), copied onto both sides, read 3.01 LU louder than the
file and got 3 dB too little gain. BS.1770 and ffmpeg's ebur128 measure
a mono programme as one channel; now so does clip_loudness, through a new
render::audio::clip_is_mono. Stereo and wider sources are unchanged, as
are the export loudness pass and the Meters panel (they measure the real
output mix).

Regression test: the same speech as a 1-channel WAV and as dual-mono
stereo measure 3.01 LU apart, the mono clip agrees with a one-channel
meter over the file within 0.1 LU, and a mono pick counts as mono.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Pathos0925 2026-10-08 12:09:15 -05:00
parent 5231852443
commit 24cde5e9fe
5 changed files with 68 additions and 8 deletions

View File

@ -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<Value> {
/// 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<f64> {
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<f32> = 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())

View File

@ -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

View File

@ -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<bool> {
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<Vec<f32>> {

View File

@ -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
```

View File

@ -186,10 +186,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).