Merge pull request #3252 from vangel76/reversed-layer-audio-3239

Audio: a time-reversed layer plays its audio backwards
This commit is contained in:
FredApps
2026-11-14 17:27:44 +01:00
committed by GitHub
3 changed files with 330 additions and 27 deletions
+2 -1
View File
@@ -244,7 +244,8 @@ pub fn preroll(id: &str, params: &Params) -> f64 {
}
/// Run audio effect `id` over interleaved stereo `buf` (L R L R …) at `rate` Hz, from a cleared
/// state. `start` is the layer time (seconds) of the first sample (oscillator phase).
/// state. `start` is the time (seconds) of the first sample since the layer's start, counted at
/// comp speed whatever the layer's time stretch (oscillator phase).
pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64) {
if rate == 0 || buf.len() < 2 {
return;
+111 -25
View File
@@ -12,8 +12,11 @@
//! 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.
//! Time-stretched layers are resampled linearly, so pitch follows the speed. A reversed layer
//! (negative stretch, Layer > Time > Time-Reverse Layer) plays its audio backwards at the same
//! speed: comp sample `k` plays the source just before layer time `layer_time(k)`, so at -100 %
//! with the start at the out point (After Effects' layout) it is the exact mirror of the
//! layer's span. Backwards on a reversed layer plays it forwards again.
use effectcraft_effects::Params;
use effectcraft_effects::audio_fx;
@@ -24,9 +27,18 @@ use crate::{EvalCtx, ExprHost, FootageSource};
const MAX_DEPTH: usize = 16;
/// The fastest a layer's audio plays (a Stretch of 1%).
/// Fastest a layer's audio plays (source samples per comp sample): 1 % stretch, the smallest
/// Time Stretch accepts. The mix plays the clamped stretch (`Layer::effective_stretch`, #3247),
/// so a layer is never faster; a faster speed would be silent instead of reading unbounded
/// source: a last-resort guard.
const MAX_SPEED: f64 = 100.0;
/// Most source sample frames one read asks a footage item or precomp for; a longer read is made
/// in pieces, so memory stays bounded however fast a layer plays and however deep stretched
/// precomps nest. The work is not bounded: each 1 % precomp level still reads 100 times its
/// span.
pub(crate) const MAX_READ: usize = 1 << 18;
/// The most effect preroll (history before a block) mixed, in seconds, across a layer's effects.
const MAX_PREROLL_SECONDS: f64 = 30.0;
@@ -118,6 +130,13 @@ fn animated(g: &effectcraft_project::PropGroup) -> bool {
})
}
/// The clock (seconds) a layer's audio effects run on at comp time `t` (oscillator phase): comp
/// time since the layer's start. It advances one sample per sample at any stretch, forwards or
/// reversed, so blocks join up; at 100 % it is the layer time.
fn fx_clock(l: &Layer, t: Tick) -> f64 {
Tick(t.0.saturating_sub(l.start_time.0)).seconds()
}
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));
@@ -143,7 +162,7 @@ impl MixEnv<'_> {
/// The layer's own (pre-effect) audio for comp sample indices `i0 .. i0 + n`, silent
/// outside its [in, out) span. Comp sample index `k` maps to `in + out − 1 − k` when
/// `reversed`.
/// `reversed` (the Backwards effect).
fn layer_raw(&self, l: &Layer, i0: i64, n: usize, reversed: bool) -> Vec<f32> {
let rate = self.rate;
let mut out = vec![0.0f32; n * 2];
@@ -170,42 +189,86 @@ impl MixEnv<'_> {
if a >= b {
return out;
}
let m = b - a;
// Within ±1 % to ±9900 % (#3247): a stretch of 1e-300 % set in memory read about
// usize::MAX source samples for one block.
let speed = (100.0 / l.effective_stretch()).min(MAX_SPEED);
let src_n = ((m as f64 * speed).ceil() as usize).max(1);
let lt = l.layer_time(sample_tick(i0 + a as i64, rate));
// usize::MAX source samples for one block. A negative stretch plays backwards (#3239).
let speed = 100.0 / l.effective_stretch();
let aspeed = speed.abs();
// The clamped stretch keeps the speed within MAX_SPEED; NaN is checked anyway. The
// tolerance keeps exactly 1 % audible whatever the float noise in the stretch.
if aspeed.is_nan() || aspeed > MAX_SPEED * (1.0 + 1e-9) {
return out;
}
let backwards = speed < 0.0;
// Comp samples per piece, so that one piece reads at most MAX_READ source frames (its
// span, the fraction it starts past a source sample, and the sample after its end).
let piece = (((MAX_READ - 4) as f64 / aspeed) as usize).clamp(1, b - a);
let mut p0 = a;
while p0 < b {
let p1 = p0.saturating_add(piece).min(b);
// Forwards a piece reads the source from the layer time of its first sample. Backwards
// it reads from the layer time of its end and plays what it read last to first, so
// comp sample `k` plays the source just before `layer_time(k)` — at -100 % the exact
// mirror Backwards gives a forward layer.
let origin = sample_tick(i0.saturating_add((if backwards { p1 } else { p0 }) as i64), rate);
let Some(run) = self.source_run(l, l.layer_time(origin), p1 - p0, aspeed) else { return out };
for (i, f) in run.as_chunks::<2>().0.iter().take(p1 - p0).enumerate() {
let k = if backwards { (p1 - 1).saturating_sub(i) } else { p0 + i };
if let Some(o) = out.get_mut(k * 2..k * 2 + 2) {
o.copy_from_slice(f);
}
}
p0 = p1;
}
out
}
/// `m` sample frames of the layer's source audio from layer time `lt` on, played at `speed`
/// (≥ 0) source samples per comp sample: blended linearly between source samples, or copied
/// as they are at speed 1. `None` when the layer has no source audio.
///
/// Sources deliver samples on their sample grid (from the sample at or before the time asked
/// for), so a resampled run reads from that grid sample and starts `x` at `lt`'s exact
/// position past it: a run then continues where the previous block or piece left off.
fn source_run(&self, l: &Layer, lt: Tick, m: usize, speed: f64) -> Option<Vec<f32>> {
let rate = self.rate;
let same = (speed - 1.0).abs() < 1e-9;
let s0 = lt.to_units_floor(i64::from(rate));
let from = sample_tick(s0, rate);
// `lt`'s position past sample s0, in samples (0 ≤ frac < 1); copies ignore it.
let tps = TICKS_PER_SECOND as i128;
let frac = if same { 0.0 } else { (i128::from(lt.0) * i128::from(rate)).rem_euclid(tps) as f64 / tps as f64 };
// Resampled, one more source sample than the run covers: the last sample blends with it.
let src_n = ((frac + m as f64 * speed).ceil() as usize).saturating_add(usize::from(!same)).max(1);
let buf = match &l.source {
LayerSource::Footage { item } => match self.project.item(*item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) if f.has_audio => self.footage.audio(*item, f, lt, src_n, rate).unwrap_or_else(|| vec![0.0; src_n * 2]),
_ => return out,
Some(ItemKind::Footage(f)) if f.has_audio => self.footage.audio(*item, f, from, src_n, rate).unwrap_or_else(|| vec![0.0; src_n * 2]),
_ => return None,
},
LayerSource::Comp { item } => {
if self.project.comp_contains(*item, self.comp_id) {
return out;
return None;
}
let mut sub = vec![0.0f32; src_n * 2];
mix_into(&AudioSources { project: self.project, footage: self.footage, expr: self.expr, rate }, *item, lt, &mut sub, self.depth + 1);
mix_into(&AudioSources { project: self.project, footage: self.footage, expr: self.expr, rate }, *item, from, &mut sub, self.depth + 1);
sub
}
_ => return out,
_ => return None,
};
let same = (speed - 1.0).abs() < 1e-9;
let mut out = vec![0.0f32; m * 2];
for i in 0..m {
// The source sample at fractional index `x`, blended linearly with the next one unless the
// layer plays at its own speed (then the samples are copied as they are).
let x = i as f64 * speed;
let x = frac + i as f64 * speed;
let j = if same { i } else { (x as usize).min(src_n - 1) };
if j * 2 + 1 >= buf.len() {
break;
}
let k = if same || (j + 1) * 2 + 1 >= buf.len() { j } else { j + 1 };
let f = if same { 0.0 } else { ((x - j as f64) as f32).clamp(0.0, 1.0) };
out[(a + i) * 2] = buf[j * 2] + (buf[k * 2] - buf[j * 2]) * f;
out[(a + i) * 2 + 1] = buf[j * 2 + 1] + (buf[k * 2 + 1] - buf[j * 2 + 1]) * f;
out[i * 2] = buf[j * 2] + (buf[k * 2] - buf[j * 2]) * f;
out[i * 2 + 1] = buf[j * 2 + 1] + (buf[k * 2 + 1] - buf[j * 2 + 1]) * f;
}
out
Some(out)
}
/// Effect parameters at comp time `t`.
@@ -233,7 +296,7 @@ impl MixEnv<'_> {
let pre = (pre_s * self.rate as f64).ceil() as usize;
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();
let start = fx_clock(l, sample_tick(b0, self.rate));
for (id, g, p) in &fx {
if audio_fx::per_sample(id) && animated(g) {
self.process_stepped(l, id, g, &mut buf, b0);
@@ -257,7 +320,7 @@ impl MixEnv<'_> {
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());
audio_fx::process(id, &self.params(l, g, t), piece, self.rate, fx_clock(l, t));
}
k = end;
}
@@ -309,8 +372,9 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut
let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l));
let s0 = start.to_units_floor(rate as i64);
for l in &comp.layers {
// The stretch the layer's time uses (#3247): 0 and NaN play at 100 %, as `layer_time`.
if !audible(project, l) || (any_solo && !l.switches.solo) || l.effective_stretch() <= 0.0 {
// The stretch the layer's time uses (#3247): 0 and NaN play at 100 %, as `layer_time`;
// a negative one plays backwards (#3239).
if !audible(project, l) || (any_solo && !l.switches.solo) {
continue;
}
// Sample range of the block that falls inside the layer's [in, out).
@@ -552,6 +616,11 @@ mod tests {
let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
let mut tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
tone.stretch = stretch;
// A reversed layer as After Effects lays it out: over [0, 2) s, starting at its out point.
let end = if stretch < 0.0 { 2.0 } else { 0.0 };
if stretch < 0.0 {
(tone.in_point, tone.out_point, tone.start_time) = (Tick::ZERO, Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(2.0));
}
let mut comp = base.clone();
comp.layers = vec![tone].into();
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
@@ -561,8 +630,11 @@ mod tests {
let speed = 100.0 / stretch;
let (mut worst, mut sq) = (0.0f64, 0.0f64);
for i in 0..n {
// The source plays `speed` times as fast as the comp: source time = speed * comp time.
let ideal = (std::f64::consts::TAU * 440.0 * speed * (t.seconds() + i as f64 / rate as f64)).sin() * 0.5;
// The source plays `speed` times as fast as the comp: source time = speed * (comp time −
// start). Reversed, a comp sample plays the source sample just before that time.
let back = if stretch < 0.0 { speed / rate as f64 } else { 0.0 };
let src = speed * (t.seconds() + i as f64 / rate as f64 - end) + back;
let ideal = (std::f64::consts::TAU * 440.0 * src).sin() * 0.5;
let e = (m[i * 2] as f64 - ideal).abs();
worst = worst.max(e);
sq += e * e;
@@ -581,4 +653,18 @@ mod tests {
assert!(worst < 1e-2, "stretch {stretch}%: error {worst} at 8 kHz");
}
}
/// A reversed layer plays its tone backwards with the same resampling quality as forwards
/// (it was silent, #3239); at -100 % the samples are copied exactly.
#[test]
fn reversed_layers_resample_a_tone_with_small_error() {
for stretch in [-70.0, -33.0] {
let (worst, _) = stretch_error(stretch, 48_000);
assert!(worst < 1e-3, "stretch {stretch}%: error {worst} at 48 kHz");
let (worst, _) = stretch_error(stretch, 8_000);
assert!(worst < 1e-2, "stretch {stretch}%: error {worst} at 8 kHz");
}
let (worst, _) = stretch_error(-100.0, 48_000);
assert!(worst < 1e-5, "stretch -100%: error {worst}");
}
}
+217 -1
View File
@@ -1,13 +1,14 @@
//! Audio effects through the mixdown (the path preview playback and export share).
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
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};
use crate::audio::{comp_has_audio, footage_peaks, mix_comp, peak_summary};
use crate::audio::{MAX_READ, comp_has_audio, footage_peaks, mix_comp, peak_summary};
use crate::{FootageSource, Image};
const SR: u32 = 8000;
@@ -109,6 +110,221 @@ fn backwards_reverses_the_layer_span() {
assert!((m[4000 * 2] - 0.5).abs() < 1e-3);
}
/// `comp_with`, its layer reversed at `stretch` (< 0) the way After Effects lays it out: in 0,
/// out `out` s, starting at the out point.
fn reversed(id: Option<&str>, vals: &[(&str, Value)], solid: bool, stretch: f64, out: f64) -> (Project, ItemId) {
let (mut p, cid) = comp_with(id, vals, solid);
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.stretch = stretch;
(l.in_point, l.out_point, l.start_time) = (Tick::ZERO, Tick::from_seconds_f64(out), Tick::from_seconds_f64(out));
(p, cid)
}
/// A time-reversed layer plays its audio backwards instead of being silent (#3239): at -100 %
/// sample for sample what Backwards plays on the forward layer, in any block size.
#[test]
fn a_reversed_layer_plays_its_audio_backwards() {
let n = SR as usize;
let (p, cid) = reversed(None, &[], false, -100.0, 1.0);
let m = mix(&p, &Signal(ramp), cid, 0.0, n);
// Comp sample k plays source sample n − 1 − k.
for k in [0usize, 1, 2000, 4000, 7000] {
let want = (n - 1 - k) as f32 / SR as f32;
assert!((m[k * 2] - want).abs() < 1e-6 && (m[k * 2 + 1] + want).abs() < 1e-6, "k={k}: {} {}, want {want}", m[k * 2], m[k * 2 + 1]);
}
let (bp, bcid) = comp_with(Some("ec.audio.backwards"), &[], false);
assert_eq!(m, mix(&bp, &Signal(ramp), bcid, 0.0, n), "not the mirror Backwards plays");
let mut blocks = vec![];
for k in 0..n / 320 {
blocks.extend(mix(&p, &Signal(ramp), cid, (k * 320) as f64 / SR as f64, 320));
}
let worst = m.iter().zip(&blocks).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-6, "block size changes the result by {worst}");
}
/// The speed of a reversed layer follows its stretch as a forward one's does: -50 % plays the
/// source twice as fast, -200 % at half speed, both backwards.
#[test]
fn a_reversed_layer_plays_at_its_stretch() {
// -50 % over 0.5 s: comp sample k plays source sample 2 (n − 1 − k), n = 4000.
let (p, cid) = reversed(None, &[], false, -50.0, 0.5);
let m = mix(&p, &Signal(ramp), cid, 0.0, 4000);
for k in [0usize, 1000, 3000] {
let want = 2.0 * (3999 - k) as f32 / SR as f32;
assert!((m[k * 2] - want).abs() < 1e-6, "-50 %, k={k}: {}, want {want}", m[k * 2]);
}
// -200 % over 2 s: comp sample k plays source sample (n − 1 − k) / 2, n = 16000, blended.
let (p, cid) = reversed(None, &[], false, -200.0, 2.0);
let m = mix(&p, &Signal(ramp), cid, 0.0, 16_000);
for k in [0usize, 1, 5001, 12_000] {
let want = (15_999 - k) as f32 / 2.0 / SR as f32;
assert!((m[k * 2] - want).abs() < 1e-5, "-200 %, k={k}: {}, want {want}", m[k * 2]);
}
}
/// Backwards on a reversed layer plays it forwards: the two reversals cancel.
#[test]
fn backwards_on_a_reversed_layer_plays_forwards() {
let (p, cid) = reversed(Some("ec.audio.backwards"), &[], false, -100.0, 1.0);
let (fp, fcid) = comp_with(None, &[], false);
for (from, frames) in [(0.0, SR as usize), (0.25, 2000)] {
let m = mix(&p, &Signal(ramp), cid, from, frames);
assert!(m.iter().any(|v| v.abs() > 0.1), "silent from {from} s");
assert_eq!(m, mix(&fp, &Signal(ramp), fcid, from, frames), "from {from} s");
}
}
/// Effects on a reversed layer run on a clock that moves forwards with the samples at comp speed,
/// so a Tone joins up across blocks (preview's frame blocks and export's one-second blocks
/// agree), at -50 % too. On the layer-time clock, block k at -100 % would start 0.08 k s off:
/// 35.2 k cycles at 440 Hz, a phase jump.
#[test]
fn tone_on_a_reversed_layer_joins_up_across_blocks() {
let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(440.0))];
for k in ["frequency2", "frequency3", "frequency4", "frequency5"] {
vals.push((k, Value::Scalar(0.0)));
}
for stretch in [-100.0, -50.0] {
let (p, cid) = reversed(Some("ec.audio.tone"), &vals, true, stretch, 1.0);
let long = mix(&p, &Signal(ramp), cid, 0.2, 1600);
assert!(rms(&long) > 0.05, "{stretch} %: silent: {}", rms(&long));
let mut blocks = vec![];
for k in 0..5 {
blocks.extend(mix(&p, &Signal(ramp), cid, 0.2 + k as f64 * 0.04, 320));
}
let worst = long.iter().zip(&blocks).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-3, "{stretch} %: block size changes the tone by {worst}");
}
}
/// Footage audio on its sample grid, as the media pool decodes it: a read from any time starts
/// at the sample at or before it. Sample n is `f(n / rate)` over [0, 2) s.
struct Grid(fn(f64) -> (f32, f32));
impl FootageSource for Grid {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
let s0 = start.to_units_floor(i64::from(rate));
Some(
(0..frames as i64)
.flat_map(|i| {
let t = (s0 + i) as f64 / rate as f64;
let (l, r) = if (0.0..2.0).contains(&t) { (self.0)(t) } else { (0.0, 0.0) };
[l, r]
})
.collect(),
)
}
}
/// A stretched layer, forwards or reversed, gives the same samples mixed in one block as in
/// blocks of any size (preview's video-frame blocks, export's seconds): each block resamples
/// from the exact source position, not from the source sample a block happens to start on.
#[test]
fn stretched_layers_join_up_across_blocks() {
// (stretch, out point, start): the reversed layers start at their out point, the -50 % one
// off the sample grid (a third of a sample late), so its source positions are fractional.
let third = Tick(Tick::from_seconds_f64(1.0 / SR as f64).0 / 3);
for (stretch, out, start) in [
(200.0, 2.0, Tick::ZERO),
(-70.0, 0.7, Tick::from_seconds_f64(0.7)),
(-50.0, 0.5, Tick(Tick::from_seconds_f64(0.5).0 + third.0)),
(-200.0, 2.0, Tick::from_seconds_f64(2.0)),
] {
let (mut p, cid) = comp_with(None, &[], false);
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.stretch = stretch;
(l.in_point, l.out_point, l.start_time) = (Tick::ZERO, Tick::from_seconds_f64(out), start);
let n = 3210;
let long = mix(&p, &Grid(ramp), cid, 0.1, n);
assert!(long.iter().any(|v| v.abs() > 0.05), "{stretch} %: silent");
for block in [321, 107] {
let mut blocks = vec![];
for k in 0..n / block {
blocks.extend(mix(&p, &Grid(ramp), cid, 0.1 + (k * block) as f64 / SR as f64, block));
}
let worst = long.iter().zip(&blocks).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-6, "{stretch} %, blocks of {block}: off by {worst}");
}
}
}
/// At 200 % the last sample of a block blends with the source sample after it, as inside the
/// block, instead of holding the one before.
#[test]
fn a_stretched_block_ends_on_a_blended_sample() {
let (mut p, cid) = comp_with(None, &[], false);
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.stretch = 200.0;
(l.in_point, l.out_point, l.start_time) = (Tick::ZERO, Tick::from_seconds_f64(2.0), Tick::ZERO);
let m = mix(&p, &Grid(ramp), cid, 0.0, 320);
// Comp sample 319 plays source position 159.5.
let want = 159.5 / SR as f32;
assert!((m[319 * 2] - want).abs() < 1e-6, "{}, want {want}", m[319 * 2]);
}
/// Records the longest audio read and answers with a short run, so an unbounded request fails
/// the assert below instead of allocating.
struct Longest(AtomicUsize);
impl FootageSource for Longest {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, _: u32) -> Option<Vec<f32>> {
self.0.fetch_max(frames, Ordering::Relaxed);
Some(vec![0.25; frames.min(4096) * 2])
}
}
/// Tiny, huge, zero and non-finite stretches, and fast precomps nested in fast precomps, read
/// at most MAX_READ source frames at a time and mix to finite samples (a tiny positive stretch
/// asked for 10⁸ times the block, and its precomp buffer could abort the process).
#[test]
fn extreme_stretches_read_bounded_source() {
let stretches = [1e-6, -1e-6, 0.5, -0.5, 1.0, -1.0, 1e12, -1e12, f64::MIN_POSITIVE, 0.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
for stretch in stretches {
let (mut p, cid) = comp_with(None, &[], false);
p.comp_mut(cid).unwrap().layers[0].stretch = stretch;
let src = Longest(AtomicUsize::new(0));
let m = mix(&p, &src, cid, 0.0, 800);
let longest = src.0.load(Ordering::Relaxed);
assert!(longest <= MAX_READ, "stretch {stretch}: read {longest} frames at once");
assert!(m.iter().all(|v| v.is_finite()), "stretch {stretch}");
}
// Exactly 1 %, give or take float noise, stays audible.
let (mut p, cid) = comp_with(None, &[], false);
p.comp_mut(cid).unwrap().layers[0].stretch = 1.0 - 1e-12;
let m = mix(&p, &Longest(AtomicUsize::new(0)), cid, 0.0, 800);
assert!(m.iter().any(|v| *v != 0.0), "1 % is silent");
// A 1 % footage layer in a 1 % precomp: 10⁴ source samples per comp sample. The precomp layer
// lasts 0.02 s, which plays the inner layer's 2 s once (backwards from its out point at -1 %).
for sign in [1.0, -1.0] {
let mut p = Project::default();
let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(audio_footage()));
let mut inner_l = build::layer(&mut p, &base, "a", LayerSource::Footage { item: fid }, (64, 64), None);
inner_l.stretch = sign;
(inner_l.in_point, inner_l.out_point, inner_l.start_time) =
(Tick::ZERO, Tick::from_seconds_f64(2.0), if sign < 0.0 { Tick::from_seconds_f64(2.0) } else { Tick::ZERO });
let mut inner = base.clone();
inner.layers = vec![inner_l].into();
let inner_id = p.add_item("Inner", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut pre = build::layer(&mut p, &base, "pre", LayerSource::Comp { item: inner_id }, (64, 64), None);
pre.stretch = sign;
let pre_out = Tick::from_seconds_f64(0.02);
(pre.in_point, pre.out_point, pre.start_time) = (Tick::ZERO, pre_out, if sign < 0.0 { pre_out } else { Tick::ZERO });
let mut outer = base.clone();
outer.layers = vec![pre].into();
let outer_id = p.add_item("Outer", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(outer.into()));
let src = Longest(AtomicUsize::new(0));
let m = mix(&p, &src, outer_id, 0.0, 800);
let longest = src.0.load(Ordering::Relaxed);
assert!(longest > 0 && longest <= MAX_READ, "nested {sign}%: read {longest} frames at once");
assert!(m.iter().all(|v| v.is_finite()));
}
}
#[test]
fn high_low_pass_attenuates() {
fn hi(t: f64) -> (f32, f32) {