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 <noreply@anthropic.com>
This commit is contained in:
taehong
2026-10-29 17:57:44 +09:00
co-authored by Claude Opus 5.5
parent 990fa668cd
commit 6fc64bf512
3 changed files with 125 additions and 24 deletions
+6
View File
@@ -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 {
+82 -23
View File
@@ -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<f32> {
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]> {
+37 -1
View File
@@ -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<f32> {
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))];