From e87e1b763ecbf2a6b61fc19a68ab06be8d0e0732 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:18 +0000 Subject: [PATCH] Effects: Audio Spectrum and Audio Waveform follow time in the viewer (#209) Both visualisers read their Audio Layer's samples around the frame time, but they weren't in TIME_DEPENDENT, so the viewer's layer cache gave the layer one key for every frame and showed the first frame's spectrum for the whole preview. Export doesn't use that cache, which is why it rendered correctly. Test: audio_spectrum_follows_time_through_the_layer_cache renders a tone that starts at 0.5 s through a shared layer cache after t = 0 and compares it with an uncached render (it showed the stale baseline). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/effects/src/lib.rs | 3 +++ crates/render/src/tests_audio_fx.rs | 39 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index fd28626f..5f832c20 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -1107,6 +1107,9 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.obsolete.lightning", "ec.text.timecode", "ec.text.numbers", + // The audio visualisers read the Audio Layer's samples around the frame time. + "ec.generate.audiospectrum", + "ec.generate.audiowaveform", // Path Text's jitter changes every frame. "ec.obsolete.pathtext", // Time effects read neighbouring frames of the layer. diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index 4cb4e5e7..aced8208 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -214,3 +214,42 @@ fn peak_summary_bins() { assert_eq!(peaks.len(), 200); assert!((peaks[150][1] - 1.51).abs() < 0.01, "{:?}", peaks[150]); } + +/// Audio Spectrum reads its Audio Layer's samples at the frame time, so the layer cache keys it +/// by time: a cached frame after the sound starts matches an uncached render (#209). +#[test] +fn audio_spectrum_follows_time_through_the_layer_cache() { + fn late_tone(t: f64) -> (f32, f32) { + let v = if t < 0.5 { 0.0 } else { (2.0 * std::f64::consts::PI * 1000.0 * t).sin() as f32 }; + (v, v) + } + let mut p = Project::default(); + let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); + let audio = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(audio_footage())); + let a = build::layer(&mut p, &comp, "a", LayerSource::Footage { item: audio }, (64, 64), None); + let sid = p.add_item("S", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 64, height: 64, pixel_aspect: 1.0 })); + let mut s = build::layer(&mut p, &comp, "s", LayerSource::Solid { item: sid }, (64, 64), None); + let spec = effectcraft_effects::find("ec.generate.audiospectrum").unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 64.0]); + p.next_id = next; + g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0)); + s.props.sub_mut("effects").unwrap().children.push(g.into()); + let mut comp = comp; + comp.layers = vec![s, a]; + let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); + + let src = Signal(late_tone); + let render = |t: f64, cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(&p, &src, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, Tick::from_seconds_f64(t)) + }; + let lit = |img: &Image| img.data.iter().filter(|px| px[0] + px[1] + px[2] > 0.1).count(); + let cache = crate::LayerCache::default(); + let silent = render(0.0, Some(&cache)); + let cached = render(0.8, Some(&cache)); + let fresh = render(0.8, None); + assert!(lit(&fresh) > lit(&silent), "the tone draws more than the baseline: {} vs {}", lit(&fresh), lit(&silent)); + assert_eq!(cached.data, fresh.data, "cached {} px, uncached {} px", lit(&cached), lit(&fresh)); +}