diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index c4cb50de2..d8c0a98f3 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -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; diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 2c1538133..71e18fdaf 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -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 { 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> { + 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}"); + } } diff --git a/crates/render/src/tests/audio_fx.rs b/crates/render/src/tests/audio_fx.rs index a6c85f2de..5ef2949e5 100644 --- a/crates/render/src/tests/audio_fx.rs +++ b/crates/render/src/tests/audio_fx.rs @@ -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> { + None + } + fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option> { + 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> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, _: u32) -> Option> { + 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) {