mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
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:
co-authored by
Claude Opus 5.5
parent
990fa668cd
commit
6fc64bf512
@@ -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
@@ -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]> {
|
||||
|
||||
@@ -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))];
|
||||
|
||||
Reference in New Issue
Block a user