#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:
parent
5231852443
commit
24cde5e9fe
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue