From 6fc64bf512a95c94198d9757cdb1ce1e10b637e9 Mon Sep 17 00:00:00 2001 From: taehong Date: Thu, 29 Oct 2026 17:57:44 +0900 Subject: [PATCH] Audio: keyframed Audio Levels and Stereo Mixer levels ramp within a mix block The mixdown evaluated levels and effect parameters once per block, and export mixes WAV/AIFF in one-second blocks, so a level fade became a staircase that changed once a second. Animated Audio Levels are now evaluated on a 1 ms grid of comp samples and interpolated in dB between, and animated per-sample effects (Stereo Mixer, Backwards) run in 1 ms pieces with their parameters at each piece. The grid is aligned to comp sample indices, so the result still does not depend on the block size. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/audio_fx.rs | 6 ++ crates/render/src/audio.rs | 105 ++++++++++++++++++++++------ crates/render/src/tests/audio_fx.rs | 38 +++++++++- 3 files changed, 125 insertions(+), 24 deletions(-) diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index cd9153d66..2aab70309 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool { id == "ec.audio.backwards" } +/// Does each output sample depend only on the input sample at the same time (no state), so the +/// mixer may run the effect in short pieces with parameters animated between them? +pub fn per_sample(id: &str) -> bool { + matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards") +} + /// Seconds of input history the effect needs before a block to reach the state it would have /// had when processing continuously. pub fn preroll(id: &str, params: &Params) -> f64 { diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 66b54b6d0..ee19d8b25 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -4,10 +4,13 @@ //! (Effect > Audio, see [`effectcraft_effects::audio_fx`]) run on each layer's audio before //! its levels; Backwards plays the layer's [in, out) span reversed. //! -//! Callers mix in short blocks (one video frame, or an audio device buffer). Effects and levels -//! are evaluated once per block. To make the result independent of block boundaries, layer -//! effects are run from a cleared state over a pre-roll of earlier audio and only the block's -//! tail is kept — so preview playback and export produce identical samples. +//! Callers mix in short blocks (one video frame, an audio device buffer, or a second of export). +//! Effect parameters are evaluated once per block. Animated Audio Levels, and animated effects +//! that work sample by sample (Stereo Mixer), are evaluated on a grid of comp samples every +//! millisecond instead, so keyframed fades ramp smoothly whatever the block size. To make the +//! result independent of block boundaries, layer effects are run from a cleared state over a +//! pre-roll of earlier audio and only the block's tail is kept — so preview playback and export +//! produce identical samples. //! //! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are //! silent for now. @@ -85,6 +88,20 @@ fn units_ceil(t: Tick, rate: u32) -> i64 { (n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64 } +/// Samples between evaluations of animated levels and per-sample effects: one millisecond, on +/// a grid of comp sample indices so the result does not depend on where a block starts. +fn param_step(rate: u32) -> i64 { + i64::from(rate / 1000).max(1) +} + +/// Does any property of the group change over time? +fn animated(g: &effectcraft_project::PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.is_animated() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) +} + fn sample_tick(i: i64, rate: u32) -> Tick { // Keep the requested sample when the rate does not divide the tick clock. let t = Tick::from_units(i, i64::from(rate)); @@ -189,21 +206,70 @@ impl MixEnv<'_> { /// `i0 .. i0 + n`. fn layer_audio(&self, l: &Layer, i0: i64, n: usize) -> Vec { let t = sample_tick(i0, self.rate); - let fx: Vec<(&str, Params)> = audio_effect_groups(l).map(|(id, g)| (id, self.params(l, g, t))).collect(); + let fx: Vec<(&str, &effectcraft_project::PropGroup, Params)> = audio_effect_groups(l).map(|(id, g)| (id, g, self.params(l, g, t))).collect(); if fx.is_empty() { return self.layer_raw(l, i0, n, false); } - let reversed = fx.iter().any(|(id, _)| audio_fx::reverses(id)); - let pre_s: f64 = fx.iter().map(|(id, p)| audio_fx::preroll(id, p)).sum(); + let reversed = fx.iter().any(|(id, _, _)| audio_fx::reverses(id)); + let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum(); let pre = (pre_s * self.rate as f64).ceil() as usize; - let mut buf = self.layer_raw(l, i0 - pre as i64, n + pre, reversed); - let start = l.layer_time(sample_tick(i0 - pre as i64, self.rate)).seconds(); - for (id, p) in &fx { - audio_fx::process(id, p, &mut buf, self.rate, start); + let b0 = i0 - pre as i64; + let mut buf = self.layer_raw(l, b0, n + pre, reversed); + let start = l.layer_time(sample_tick(b0, self.rate)).seconds(); + for (id, g, p) in &fx { + if audio_fx::per_sample(id) && animated(g) { + self.process_stepped(l, id, g, &mut buf, b0); + } else { + audio_fx::process(id, p, &mut buf, self.rate, start); + } } buf.drain(..pre * 2); buf } + + /// Run per-sample effect `id` over `buf` (comp samples from `b0`) in pieces of the parameter + /// grid, each with the parameters at its first sample. + fn process_stepped(&self, l: &Layer, id: &str, g: &effectcraft_project::PropGroup, buf: &mut [f32], b0: i64) { + let step = param_step(self.rate); + let frames = buf.len() / 2; + let mut k = 0usize; + while k < frames { + let i = b0.saturating_add(k as i64); + let next = i.div_euclid(step).saturating_add(1).saturating_mul(step); + let end = usize::try_from(next.saturating_sub(b0)).unwrap_or(frames).clamp(k + 1, frames); + let t = sample_tick(i, self.rate); + if let Some(piece) = buf.get_mut(k * 2..end * 2) { + audio_fx::process(id, &self.params(l, g, t), piece, self.rate, l.layer_time(t).seconds()); + } + k = end; + } + } + + /// Linear gains (left, right) of the layer's Audio Levels for comp samples `i0 .. i0 + n`. + /// Animated levels are evaluated on the parameter grid and interpolated in dB in between. + fn level_gains(&self, l: &Layer, i0: i64, n: usize) -> Vec<[f32; 2]> { + let Some(pr) = l.props.sub("audio").and_then(|g| g.get("levels")) else { return vec![[1.0, 1.0]; n] }; + let db = |i: i64| self.ctx(sample_tick(i, self.rate)).value(l, pr).as_vec2(); + let gain = |[a, b]: [f64; 2]| [db_to_gain(a), db_to_gain(b)]; + if !pr.is_animated() && !pr.has_expression() { + return vec![gain(db(i0)); n]; + } + let step = param_step(self.rate); + let end = i0.saturating_add(n as i64); + let mut out = Vec::with_capacity(n); + let mut k = i0.div_euclid(step).saturating_mul(step); + let mut lo = db(k); + while k < end { + let hi = db(k.saturating_add(step)); + for i in k.max(i0)..k.saturating_add(step).min(end) { + let f = (i - k) as f64 / step as f64; + out.push(gain([lo[0] + (hi[0] - lo[0]) * f, lo[1] + (hi[1] - lo[1]) * f])); + } + k = k.saturating_add(step); + lo = hi; + } + out + } } /// Shared inputs for composition audio mixing, including nested compositions. @@ -234,25 +300,18 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut if a >= b { continue; } - let t_a = sample_tick(s0 + a as i64, rate); - let (ll, lr) = levels(&env, l, t_a); - if ll == 0.0 && lr == 0.0 { + let gains = env.level_gains(l, s0 + a as i64, b - a); + if gains.iter().all(|g| *g == [0.0, 0.0]) { continue; } let buf = env.layer_audio(l, s0 + a as i64, b - a); - for (i, f) in buf.as_chunks::<2>().0.iter().enumerate() { - out[(a + i) * 2] += f[0] * ll; - out[(a + i) * 2 + 1] += f[1] * lr; + for (i, (f, g)) in buf.as_chunks::<2>().0.iter().zip(&gains).enumerate() { + out[(a + i) * 2] += f[0] * g[0]; + out[(a + i) * 2 + 1] += f[1] * g[1]; } } } -fn levels(env: &MixEnv, l: &Layer, t: Tick) -> (f32, f32) { - let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) }; - let [a, b] = env.ctx(t).v2(l, g, "levels", [0.0, 0.0]); - (db_to_gain(a), db_to_gain(b)) -} - /// A min/max peak summary of interleaved stereo audio for waveform drawing: one entry per /// `bin` sample frames, `[min L, max L, min R, max R]`. pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> { diff --git a/crates/render/src/tests/audio_fx.rs b/crates/render/src/tests/audio_fx.rs index aced8208e..242a5a5e2 100644 --- a/crates/render/src/tests/audio_fx.rs +++ b/crates/render/src/tests/audio_fx.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Keyframe, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; @@ -148,6 +148,42 @@ fn stereo_mixer_pans() { assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]); } +/// Key `prop` of layer 0 (`effect`: in its first effect, else in Audio) from `a` at 0 s to `b` at 1 s. +fn key(p: &mut Project, cid: ItemId, effect: bool, prop: &str, a: Value, b: Value) { + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let g = if effect { l.props.sub_mut("effects").unwrap().children[0].as_group_mut().unwrap() } else { l.props.sub_mut("audio").unwrap() }; + g.get_mut(prop).unwrap().keys = vec![Keyframe::new(Tick::ZERO, a), Keyframe::new(Tick::from_seconds_f64(1.0), b)]; +} + +/// Left samples of a mix of constant 0.5 audio over the layer's 1 s, in blocks of `block` samples. +fn left_ramp(p: &Project, cid: ItemId, block: usize) -> Vec { + fn half(_: f64) -> (f32, f32) { + (0.5, 0.5) + } + let mut m = vec![]; + for k in 0..SR as usize / block { + m.extend(mix(p, &Signal(half), cid, (k * block) as f64 / SR as f64, block)); + } + m.iter().step_by(2).copied().collect() +} + +/// Keyframed Audio Levels and Stereo Mixer levels ramp smoothly inside a block (export mixes one +/// second at a time), instead of holding the value at the block's first sample. +#[test] +fn animated_levels_ramp_within_a_block() { + let (mut levels, lc) = comp_with(None, &[], false); + key(&mut levels, lc, false, "levels", Value::Vec2([-60.0, -60.0]), Value::Vec2([0.0, 0.0])); + let (mut mixer, mc) = comp_with(Some("ec.audio.stereomixer"), &[], false); + key(&mut mixer, mc, true, "leftLevel", Value::Scalar(0.0), Value::Scalar(100.0)); + for (name, p, cid, mid) in [("levels", &levels, lc, 0.5 * 10f32.powf(-1.5)), ("stereo mixer", &mixer, mc, 0.25)] { + let one = left_ramp(p, cid, SR as usize); + assert!(one.windows(2).all(|w| w[1] >= w[0]), "{name}: not monotonic"); + assert!(one[100] < one[4000] && one[4000] < one[7900], "{name}: {} {} {}", one[100], one[4000], one[7900]); + assert!((one[4000] / mid - 1.0).abs() < 0.05, "{name}: {} at 0.5 s, want {mid}", one[4000]); + assert_eq!(one, left_ramp(p, cid, 320), "{name}: block size changes the result"); + } +} + #[test] fn tone_makes_a_solid_audible_at_its_frequency() { let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))];