From 998448f94be3474e164d4feb0fcc2cdd97a4a2d1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:41:19 +0100 Subject: [PATCH] Audio: a time-reversed layer plays its audio backwards (#3239) A layer with a negative time stretch was skipped by the mix. It now plays its audio reversed at its stretched speed, through the same linear resampler as forward stretch: a reversed read starts at the layer time of the run's end and is played last to first, so comp sample k plays the source just before layer_time(k) (at -100 % with the start at the out point, the exact mirror Backwards gives a forward layer). Backwards on a reversed layer plays it forwards. Resampled runs start at the exact source position (from the layer time in ticks) instead of the grid sample a block starts on, so mixing in frame blocks and in export's one-second blocks gives the same samples, and they read one more source sample so a block's last sample blends instead of holding. Audio effects run on comp time since the layer's start, which joins up across blocks at any stretch and equals the layer time at 100 %. Reads are made in pieces of at most MAX_READ source frames, so memory stays bounded for fast layers and nested fast precomps (the work for nested 1 % precomps is not bounded; follow-up). A stretch faster than 1 % is silent as a last-resort guard, with a tolerance so exactly 1 % plays. A zero or non-finite stretch is skipped. Fixes #3239 --- crates/effects/src/audio_fx.rs | 3 +- crates/render/src/audio.rs | 131 ++++++++++++++--- crates/render/src/tests/audio_fx.rs | 218 +++++++++++++++++++++++++++- 3 files changed, 329 insertions(+), 23 deletions(-) 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 e2861340c..b41307b4e 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,6 +27,18 @@ use crate::{EvalCtx, ExprHost, FootageSource}; const MAX_DEPTH: usize = 16; +/// Fastest a layer's audio plays (source samples per comp sample): 1 % stretch, the smallest +/// Time Stretch accepts. A faster layer (a stretch below 1 %, only from a damaged or foreign +/// file) is silent instead of reading unbounded source: a last-resort guard, imports clamp +/// such stretches. +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; + /// `frames` stereo sample frames of `comp`'s mix from comp time `start` at `rate` Hz, /// interleaved (L R L R …). pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec { @@ -102,6 +117,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)); @@ -127,7 +149,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]; @@ -154,40 +176,84 @@ impl MixEnv<'_> { if a >= b { return out; } - let m = b - a; let speed = 100.0 / l.stretch; - let src_n = ((m as f64 * speed).ceil() as usize).max(1); - let lt = l.layer_time(sample_tick(i0 + a as i64, rate)); + let aspeed = speed.abs(); + // A zero or non-finite stretch never gets here (see `mix_into`); 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`. @@ -215,7 +281,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); @@ -239,7 +305,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; } @@ -291,7 +357,8 @@ 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 { - if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 { + // A zero or non-finite stretch has no layer time to play; a negative one plays backwards. + if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch == 0.0 || !l.stretch.is_finite() { continue; } // Sample range of the block that falls inside the layer's [in, out). @@ -482,6 +549,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())); @@ -491,8 +563,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; @@ -511,4 +586,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) {