Skip to main content

ranim_core/animation/
mod.rs

1//! Pure animation evaluation and hierarchical type-erased composition.
2//!
3//! The runtime tree is a closed world of node content ([`NodeContent`](crate::animation::node::NodeContent)): the
4//! container children plus visual and audio leaf forms. This
5//! module is split by layer:
6//!
7//! - [`node`] — the closed runtime core and all structural interpreters;
8//! - [`eval`] — the open, typed visual-leaf protocol;
9//! - [`build`] — the lowering protocol from authoring definitions to
10//!   [`AnimNode`](crate::animation::node::AnimNode);
11//! - [`compose`] — authoring sugar (`AnimSequence`, `AnimStack`, `AnimLagged`)
12//!   that lowers to the core vocabulary.
13//!
14//! Authoring is open above the core: user leaves enter through
15//! [`Eval`](crate::animation::eval::Eval), and combinators expressible as
16//! placements (e.g. [`AnimLagged`](crate::animation::compose::lagged::AnimLagged))
17//! desugar into the primitives in
18//! [`IntoAnimNode`](crate::animation::build::IntoAnimNode).
19
20/// Authoring protocol and playback wrappers.
21pub mod build;
22/// Built-in composition containers and iterator helpers.
23pub mod compose;
24/// Evaluation protocols and author-facing adapters.
25pub mod eval;
26/// Runtime animation nodes and their interpreters.
27pub mod node;
28/// Audio leaf animation: a sound placed and composed like any animation.
29pub mod sound;
30
31#[cfg(test)]
32mod tests {
33    use super::{
34        build::{IntoAnimNode, PlaybackExt, Unplaced},
35        compose::{AnimIterExt, lagged::LaggedFill, sequence::AnimSequence, stack::AnimStack},
36        eval::{DynOutput, Eval, EvalExt, OutputContext, Static, StaticAnim, pure::Pure},
37        node::{AnimNode, AnimationInfoKind, LeafContent, NodeContent},
38    };
39    use crate::{frame::EvaluatedFrame, lagged, seq, stack};
40
41    #[derive(Clone, Default)]
42    struct PositionX(f32);
43
44    /// A stateless test double: a position fixed at x.
45    struct ShiftX(f32);
46
47    impl Eval for ShiftX {
48        type Output = PositionX;
49
50        fn eval_alpha(&self, _alpha: f64) -> Self::Output {
51            PositionX(self.0)
52        }
53    }
54
55    /// A stateless test double: x = offset + alpha.
56    struct ShiftAlpha(f32);
57
58    impl Eval for ShiftAlpha {
59        type Output = PositionX;
60
61        fn eval_alpha(&self, alpha: f64) -> Self::Output {
62            PositionX(self.0 + alpha as f32)
63        }
64    }
65
66    fn leaf(x: f32, duration: f64) -> impl Unplaced {
67        ShiftX(x).with_duration(duration)
68    }
69
70    fn progress_leaf(offset: f32) -> impl Unplaced {
71        ShiftAlpha(offset)
72    }
73
74    fn evaluated_xs(items: Vec<DynOutput>) -> Vec<f32> {
75        let mut frame = EvaluatedFrame::new();
76        let mut output = OutputContext::new(&mut frame, 0);
77        for value in items {
78            value.write(&mut output);
79        }
80        frame
81            .items::<PositionX>()
82            .iter()
83            .map(|item| item.value.0)
84            .collect()
85    }
86
87    fn sampled_xs(animation: &AnimNode, sec: f64) -> Vec<f32> {
88        let mut frame = EvaluatedFrame::new();
89        animation.eval_at(sec, &mut OutputContext::new(&mut frame, 0));
90        frame
91            .items::<PositionX>()
92            .iter()
93            .map(|item| item.value.0)
94            .collect()
95    }
96
97    #[test]
98    fn apply_alpha_to_writes_the_requested_progress_state() {
99        let mut item = PositionX::default();
100
101        let animation =
102            Pure::new(|alpha| ShiftAlpha(0.0).eval_alpha(alpha)).apply_alpha_to(&mut item, 0.25);
103        assert_eq!(item.0, 0.25);
104
105        let animation = animation.apply_to(&mut item);
106        assert_eq!(item.0, 1.0);
107        assert_eq!(animation.eval_alpha(0.5).0, 0.5);
108    }
109
110    #[test]
111    fn containers_lower_children_to_expected_timelines() {
112        let sequence = [leaf(1.0, 1.0), leaf(2.0, 2.0)].into_iter().into_seq();
113        let stack = [leaf(1.0, 1.0), leaf(2.0, 2.0)].into_iter().into_stack();
114        let lagged = [leaf(1.0, 1.0), leaf(2.0, 1.0)]
115            .into_iter()
116            .into_lagged(0.5);
117        for (node, second_range) in [
118            (sequence.into_anim_node(), 1.0..3.0),
119            (stack.into_anim_node(), 0.0..2.0),
120            (
121                lagged
122                    .with_leading(LaggedFill::Empty)
123                    .with_trailing(LaggedFill::Empty)
124                    .into_anim_node(),
125                0.5..1.5,
126            ),
127        ] {
128            assert!(matches!(node.content, NodeContent::Children(_)));
129            // Lagged adds one container per child's track.
130            let child = &node.children()[1];
131            let range = child.children().first().unwrap_or(child).time_range();
132            assert_eq!(range, second_range);
133        }
134        assert!(seq![].into_anim_node().children().is_empty());
135        assert!(stack![].into_anim_node().children().is_empty());
136    }
137
138    #[test]
139    fn runtime_children_evaluate_overlaps_in_order_and_keep_half_open_boundaries() {
140        let animation = stack![
141            progress_leaf(0.0).with_duration(2.0),
142            progress_leaf(10.0).with_duration(2.0).at(1.0),
143            leaf(100.0, 3.0).with_enabled(false),
144        ]
145        .into_anim_node();
146        assert!(matches!(animation.content, NodeContent::Children(_)));
147        assert_eq!(sampled_xs(&animation, 1.5), vec![0.75, 10.25]);
148        assert_eq!(sampled_xs(&animation, 2.0), vec![10.5]);
149        assert_eq!(sampled_xs(&animation, 3.0), vec![11.0]);
150        assert_eq!(sampled_xs(&animation, 3.1), vec![]);
151        let sequential = seq![leaf(1.0, 1.0), leaf(2.0, 1.0)].into_anim_node();
152        assert!(matches!(sequential.content, NodeContent::Children(_)));
153        assert_eq!(sampled_xs(&sequential, 1.0), vec![2.0]);
154    }
155
156    #[test]
157    fn offsets_shift_the_whole_container_timeline() {
158        let mut sequence = AnimSequence::new();
159        sequence
160            .push(leaf(1.0, 2.0))
161            .forward(1.0)
162            .push(leaf(2.0, 1.0));
163        let info = sequence.at(10.0).into_anim_node().animation_info();
164        assert_eq!(info.range, 10.0..14.0);
165        assert_eq!(info.children[0].range, 0.0..2.0);
166        assert_eq!(info.children[1].range, 3.0..4.0);
167    }
168
169    #[test]
170    fn parametrized_sequence_remaps_the_group_timeline() {
171        use crate::utils::rate_functions::ease_in_quad;
172
173        let animation = seq![progress_leaf(0.0), progress_leaf(10.0)]
174            .with_duration(4.0)
175            .with_rate_func(ease_in_quad);
176        let animation = animation.into_anim_node();
177
178        assert_eq!(animation.time_range(), 0.0..4.0);
179        assert_eq!(sampled_xs(&animation, 2.0), vec![0.5]);
180        let info = animation.animation_info();
181        assert_eq!(info.range, 0.0..4.0);
182        assert_eq!(info.content_duration_secs, 2.0);
183        assert_eq!(info.children.len(), 2);
184        assert_eq!(info.children[0].range, 0.0..1.0);
185        assert_eq!(info.children[1].range, 1.0..2.0);
186    }
187
188    #[test]
189    fn extend_appends_direct_children_and_preserves_local_gaps() {
190        let mut source = AnimSequence::new();
191        source
192            .push(leaf(2.0, 1.0))
193            .forward(2.0)
194            .push(leaf(3.0, 1.0));
195
196        let mut sequence = AnimSequence::new();
197        sequence.push(leaf(1.0, 2.0)).extend(source);
198        assert_eq!(sequence.cursor_sec(), 6.0);
199        assert_eq!(sequence.built_animations().len(), 3);
200        assert_eq!(sequence.built_animations()[0].time_range(), 0.0..2.0);
201        assert_eq!(sequence.built_animations()[1].time_range(), 2.0..3.0);
202        assert_eq!(sequence.built_animations()[2].time_range(), 5.0..6.0);
203
204        let source = stack![leaf(2.0, 1.0), leaf(3.0, 2.0).at(1.0)];
205        let mut stack = stack![leaf(1.0, 4.0)];
206        stack.extend(source);
207        assert_eq!(stack.duration_secs(), 4.0);
208        assert_eq!(stack.built_animations().len(), 3);
209        assert_eq!(stack.built_animations()[1].time_range(), 0.0..1.0);
210        assert_eq!(stack.built_animations()[2].time_range(), 1.0..3.0);
211    }
212
213    #[test]
214    fn holds_sample_active_items_and_repeat_without_nesting() {
215        let mut sequence = AnimSequence::new();
216        sequence
217            .push(stack![leaf(1.0, 1.0), leaf(2.0, 2.0), leaf(3.0, 2.0)])
218            .hold(1.0)
219            .hold(2.0);
220        assert_eq!(sequence.cursor_sec(), 5.0);
221        for (index, sec) in [(1, 2.5), (2, 4.0)] {
222            let held = &sequence.built_animations()[index];
223            let mut items = Vec::new();
224            held.capture_at(sec, &mut items);
225            // Finished children disappear; consecutive holds keep a flat batch.
226            assert_eq!(items.len(), 2);
227            assert_eq!(evaluated_xs(items), vec![2.0, 3.0]);
228            assert!(matches!(
229                held.content,
230                NodeContent::Leaf(LeafContent::Anim(_))
231            ));
232        }
233    }
234
235    #[test]
236    fn holds_use_final_evaluations_and_forward_does_not_hold() {
237        let shown = PositionX(7.0);
238        for (ending, expected) in [(shown.show(), vec![7.0]), (shown.hide(), vec![])] {
239            let mut outer = AnimSequence::new();
240            outer.push(seq![leaf(1.0, 1.0), ending]).hold(1.0);
241            assert_eq!(sampled_xs(&outer.into_anim_node(), 1.5), expected);
242        }
243        let mut gap = AnimSequence::new();
244        gap.push(leaf(4.0, 1.0)).forward(1.0).hold(1.0);
245        assert!(sampled_xs(&gap.into_anim_node(), 2.5).is_empty());
246    }
247
248    #[test]
249    fn sequence_lowering_removes_covered_points_after_holds_have_sampled_them() {
250        let first = PositionX(2.0);
251        let second = PositionX(3.0);
252        let mut sequence = AnimSequence::new();
253        sequence
254            .push(progress_leaf(0.0))
255            .push(first.show())
256            .push(second.show())
257            .hold(0.5)
258            .push(second.hide())
259            .hold(0.5);
260        let animation = sequence.into_anim_node();
261        assert_eq!(animation.children().len(), 2);
262        assert_eq!(sampled_xs(&animation, 0.5), vec![0.5]);
263        assert_eq!(sampled_xs(&animation, 1.0), vec![3.0]);
264        assert_eq!(sampled_xs(&animation, 1.25), vec![3.0]);
265        assert_eq!(sampled_xs(&animation, 1.5), vec![]);
266        assert_eq!(sampled_xs(&animation, 2.0), vec![]);
267    }
268
269    #[test]
270    fn sequence_final_points_replace_endpoints_without_affecting_stacks() {
271        let shown = PositionX(9.0);
272        let sequence = seq![progress_leaf(0.0), shown.show(), shown.hide(), shown.show()]
273            .with_duration(2.0)
274            .at(3.0)
275            .into_anim_node();
276        assert_eq!(sampled_xs(&sequence, 4.0), vec![0.5]);
277        assert_eq!(sampled_xs(&sequence, 5.0), vec![9.0]);
278        assert_eq!(sequence.children().len(), 2);
279        assert!(!sequence.children()[0].active_at(1.0));
280
281        let hidden = seq![progress_leaf(0.0), shown.hide()].into_anim_node();
282        assert_eq!(sampled_xs(&hidden, 0.5), vec![0.5]);
283        assert!(sampled_xs(&hidden, 1.0).is_empty());
284        let only_points = seq![shown.show(), shown.hide()].into_anim_node();
285        assert_eq!(only_points.children().len(), 1);
286        assert!(sampled_xs(&only_points, 0.0).is_empty());
287
288        // Stacks retain all coincident endpoints, including point animations.
289        let stack = stack![progress_leaf(0.0), shown.show().at(1.0)].into_anim_node();
290        assert_eq!(sampled_xs(&stack, 1.0), vec![1.0, 9.0]);
291    }
292
293    #[test]
294    fn lagged_staggers_and_fills_window_edges() {
295        // Stagger by a ratio of each previous child's duration.
296        let lagged = lagged![0.5; leaf(1.0, 1.0), leaf(2.0, 2.0), leaf(3.0, 1.0)];
297        assert_eq!(lagged.built_animations()[0].time_range(), 0.0..1.0);
298        assert_eq!(lagged.built_animations()[1].time_range(), 0.5..2.5);
299        assert_eq!(lagged.built_animations()[2].time_range(), 1.5..2.5);
300        assert_eq!(lagged.duration_secs(), 2.5);
301
302        // ratio 1.0 is a sequence, ratio 0.0 is a stack.
303        let as_sequence = lagged![1.0; leaf(1.0, 1.0), leaf(2.0, 1.0)];
304        assert_eq!(as_sequence.built_animations()[1].time_range(), 1.0..2.0);
305        let as_stack = lagged![0.0; leaf(1.0, 1.0), leaf(2.0, 2.0)];
306        assert_eq!(as_stack.built_animations()[1].time_range(), 0.0..2.0);
307        assert_eq!(as_stack.duration_secs(), 2.0);
308
309        // Default leading/trailing fills hold the edge states.
310        let animation = lagged![
311            0.5;
312            progress_leaf(0.0).with_duration(1.0),
313            progress_leaf(10.0).with_duration(1.0)
314        ]
315        .into_anim_node();
316        let info = animation.animation_info();
317        assert_eq!(info.kind, AnimationInfoKind::Children);
318        let track_ranges: Vec<_> = info
319            .children
320            .iter()
321            .map(|track| {
322                track
323                    .children
324                    .iter()
325                    .map(|child| child.range.clone())
326                    .collect::<Vec<_>>()
327            })
328            .collect();
329        assert_eq!(
330            track_ranges,
331            [vec![0.0..1.0, 1.0..1.5], vec![0.0..0.5, 0.5..1.5]]
332        );
333        assert_eq!(sampled_xs(&animation, 0.25), vec![0.25, 10.0]);
334        assert_eq!(sampled_xs(&animation, 1.0), vec![1.0, 10.5]);
335        assert_eq!(sampled_xs(&animation, 1.5), vec![1.0, 11.0]);
336
337        // `with_leading(Empty)` leaves the pre-window empty, trailing holds.
338        let empty_leading = lagged![
339            0.5;
340            progress_leaf(0.0).with_duration(1.0),
341            progress_leaf(10.0).with_duration(1.0)
342        ]
343        .with_leading(LaggedFill::Empty)
344        .into_anim_node();
345        assert_eq!(sampled_xs(&empty_leading, 0.25), vec![0.25]);
346        assert_eq!(sampled_xs(&empty_leading, 1.0), vec![1.0, 10.5]);
347
348        // A child ending with `hide` stays hidden after its own window.
349        let shown = PositionX(2.0);
350        let hidden = lagged![0.5; seq![leaf(2.0, 1.0), shown.hide()]].into_anim_node();
351        assert_eq!(sampled_xs(&hidden, 0.5), vec![2.0]);
352        assert!(sampled_xs(&hidden, 1.0).is_empty());
353    }
354
355    // MARK: Sound leaves in the tree
356
357    use super::sound::Sound;
358    use crate::RanimScene;
359    use crate::audio::{AudioClip, MASTER_SAMPLE_RATE};
360    use crate::utils::rate_functions::ease_in_quad;
361
362    fn tone(secs: f64) -> AudioClip {
363        // A constant-amplitude clip: probes never land on a zero crossing.
364        let pcm = vec![0.5f32; (secs * 48_000.0) as usize];
365        AudioClip::from_pcm(pcm, 48_000, 1)
366    }
367
368    /// Mix the scene's audio and report (total, first, last) in seconds —
369    /// total scene length and the first/last sample-seconds with audible
370    /// energy.
371    fn audible_region(scene: RanimScene) -> (f64, f64, f64) {
372        let sealed = scene.seal();
373        let total = sealed.total_secs();
374        let evaluator = sealed.into_evaluator(120.0);
375        let buf = evaluator.mix_audio(total, MASTER_SAMPLE_RATE);
376        let first = buf
377            .iter()
378            .position(|s| s.abs() > 1e-4)
379            .expect("expected audible samples");
380        let last = buf.len()
381            - 1
382            - buf
383                .iter()
384                .rev()
385                .position(|s| s.abs() > 1e-4)
386                .expect("non-empty");
387        (
388            total,
389            first as f64 / 2.0 / MASTER_SAMPLE_RATE as f64,
390            last as f64 / 2.0 / MASTER_SAMPLE_RATE as f64,
391        )
392    }
393
394    #[test]
395    fn sound_windows_follow_nested_placement_and_duration_overrides() {
396        for (duration, expected_start, expected_end) in [(2.0, 2.0, 3.0), (1.0, 1.5, 2.0)] {
397            let mut scene = RanimScene::new();
398            scene.play(
399                seq![leaf(1.0, 1.0), Sound::new(tone(1.0))]
400                    .with_duration(duration)
401                    .at(1.0),
402            );
403            let (total, start, end) = audible_region(scene);
404            assert_eq!(total, expected_end);
405            for (actual, expected) in [(start, expected_start), (end, expected_end)] {
406                assert!((actual - expected).abs() < 0.01, "{actual} vs {expected}");
407            }
408        }
409    }
410
411    #[test]
412    fn audio_leaves_are_silent_when_disabled_and_emit_no_visual_items() {
413        let mut scene = RanimScene::new();
414        scene.play(stack![Sound::new(tone(1.0)).with_enabled(false)]);
415        let sealed = scene.seal();
416        let mut frame = EvaluatedFrame::new();
417        sealed.eval_at_sec(0.5, &mut frame);
418        assert!(frame.is_empty());
419        let evaluator = sealed.into_evaluator(120.0);
420        assert!(evaluator.has_audio());
421        assert!(
422            evaluator
423                .mix_audio(1.0, MASTER_SAMPLE_RATE)
424                .iter()
425                .all(|s| s.abs() < 1e-4)
426        );
427        let mut visual = RanimScene::new();
428        visual.play(leaf(1.0, 1.0));
429        assert!(!visual.seal().into_evaluator(120.0).has_audio());
430        let mut audio = RanimScene::new();
431        audio.play(Sound::new(tone(0.01)));
432        audio.seal().eval_at_sec(0.005, &mut frame);
433        assert!(frame.is_empty());
434    }
435
436    #[test]
437    fn container_rate_func_warps_the_sound_window() {
438        // ease_in_quad maps scene progress u to content u²; the sound occupies
439        // content [1, 2] of 2, so it becomes audible when u² >= 1/2, at scene
440        // time 2·sqrt(1/2) ≈ 1.4142.
441        let inner = seq![leaf(1.0, 1.0), Sound::new(tone(1.0))];
442        let mut scene = RanimScene::new();
443        scene.play(inner.with_rate_func(ease_in_quad));
444
445        let (_, start, end) = audible_region(scene);
446        let expected_start = 2.0 * (0.5f64).sqrt();
447        assert!((start - expected_start).abs() < 0.01, "start {start}");
448        assert!((end - 2.0).abs() < 0.01, "end {end}");
449    }
450
451    #[test]
452    fn sound_rate_func_warps_its_content_progress() {
453        // A linear ramp clip under ease_in_quad: scene time t reads clip
454        // position t², so amplitude at t=0.5 is 0.25 (not 0.5).
455        let clip = AudioClip::from_pcm(
456            (0..48_000).map(|i| i as f32 / 48_000.0).collect::<Vec<_>>(),
457            48_000,
458            1,
459        );
460        let mut scene = RanimScene::new();
461        scene.play(Sound::new(clip).with_rate_func(ease_in_quad));
462        let evaluator = scene.seal().into_evaluator(120.0);
463        let buf = evaluator.mix_audio(1.0, 48_000);
464        // Stereo-interleaved: the L sample of frame (0.5 s × 48 kHz).
465        let mid = buf[(0.5 * 48_000.0) as usize * 2];
466        assert!((mid - 0.25).abs() < 0.01, "amplitude at 0.5s was {mid}");
467    }
468
469    #[test]
470    fn warped_container_of_warped_sounds_mixes_all_of_them() {
471        // Non-linear rates everywhere: nothing is pre-bakeable, so the whole
472        // stack must resolve through the per-sample walk — every leaf, no
473        // marking. 10 overlapping constant tones of 0.25 must sum to ~2.5.
474        let clip = AudioClip::from_pcm(vec![0.25f32; 48_000], 48_000, 1);
475        let mut stack = AnimStack::new();
476        for _ in 0..10 {
477            stack.push(Sound::new(clip.clone()).with_rate_func(ease_in_quad));
478        }
479        let mut scene = RanimScene::new();
480        scene.play(stack.with_rate_func(ease_in_quad));
481        let buf = scene.seal().into_evaluator(120.0).mix_audio(0.5, 48_000);
482        for frame in [0usize, 12_000, 23_999] {
483            assert!(
484                (buf[frame * 2] - 2.5).abs() < 1e-3,
485                "frame {frame}: {} (expected 10 × 0.25)",
486                buf[frame * 2]
487            );
488        }
489    }
490
491    #[test]
492    fn sound_with_a_tail_extends_the_scene_to_its_window() {
493        // Placement semantics are uniform: a sound's window occupies
494        // timeline space just like a visual's. Clip sample counts are
495        // quantized, so an author synthesizing to a target duration should
496        // floor (not ceil) the sample count to avoid a sub-frame tail.
497        let clip_len = 48_001; // 1.0000208..s at 48 kHz
498        let clip = AudioClip::from_pcm(vec![0.5f32; clip_len], 48_000, 1);
499        let mut scene = RanimScene::new();
500        scene.play(stack![
501            Static(PositionX::default()).with_duration(1.0),
502            Sound::new(clip)
503        ]);
504        assert!((scene.seal().total_secs() - clip_len as f64 / 48_000.0).abs() < 1e-9);
505    }
506
507    #[test]
508    fn linear_rate_is_structural() {
509        // A default-built cell must carry the structural linear rate
510        // (`None`), never an identity fn pointer — function pointer
511        // addresses are not guaranteed unique, so linearity cannot be
512        // detected by comparison. Future mixing fast paths compose affine
513        // maps only while every rate along the path is linear.
514        let cells = [
515            AnimSequence::new().into_anim_node(),
516            AnimStack::new().into_anim_node(),
517            Sound::new(tone(0.01)).into_anim_node(),
518            leaf(1.0, 1.0).into_anim_node(),
519        ];
520        for cell in &cells {
521            assert!(
522                cell.rate_func.is_none(),
523                "a default-built cell must carry the structural linear rate"
524            );
525        }
526        let warped = leaf(1.0, 1.0).with_rate_func(ease_in_quad).into_anim_node();
527        assert!(warped.rate_func.is_some());
528    }
529
530    #[test]
531    fn baked_audio_matches_point_semantics() {
532        // One scene exercising every mixing shape: sequential and
533        // overlapping sounds, duration overrides (one acting as a linear
534        // speed change on a sound), container and leaf rate warps, fades,
535        // gain, a stereo clip, and visual filler cells. The seal-time bake
536        // must equal a fresh per-sample walk of the same tree (the
537        // point-semantics spec).
538        let ramp = |i: usize| 0.4 * i as f32 / 48_000.0;
539        let stereo = AudioClip::from_pcm(
540            (0..48_000)
541                .flat_map(|i| [ramp(i), ramp(i)])
542                .collect::<Vec<_>>(),
543            48_000,
544            2,
545        );
546        let warped = seq![
547            Static(PositionX::default()).with_duration(1.0),
548            Sound::new(tone(1.0))
549        ]
550        .with_rate_func(ease_in_quad);
551
552        let mut scene = RanimScene::new();
553        scene.play(stack![
554            seq![
555                Sound::new(AudioClip::sine(440.0, 1.0, 0.5))
556                    .with_fade_in(0.25)
557                    .with_gain(0.8),
558                // A 2x speed change: half the window, pitch up an octave.
559                Sound::new(stereo).with_duration(0.5),
560            ],
561            Sound::new(AudioClip::sine(880.0, 2.0, 0.3)).at(0.5),
562            seq![Sound::new(tone(2.0))].with_duration(1.7),
563            warped,
564            Sound::new(tone(1.0)).with_rate_func(ease_in_quad),
565            Static(PositionX::default()).with_duration(4.0),
566        ]);
567        let sealed = scene.seal();
568        let baked = sealed.audio().clone();
569        let evaluator = sealed.into_evaluator(120.0);
570
571        fn cell_at(cell: &AnimNode, x: f64) -> [f32; 2] {
572            if !cell.enabled || x < cell.time_range.start || x >= cell.time_range.end {
573                return [0.0; 2];
574            }
575            let raw = (x - cell.time_range.start) / cell.duration_secs();
576            let own = cell.internal_time_secs * cell.rate_func.map_or(raw, |rate| rate(raw));
577            match &cell.content {
578                NodeContent::Leaf(LeafContent::Audio(track)) => track.sample_at(own),
579                _ => {
580                    let mut acc = [0.0f32; 2];
581                    for child in cell.children() {
582                        let [l, r] = cell_at(child, own);
583                        acc[0] += l;
584                        acc[1] += r;
585                    }
586                    acc
587                }
588            }
589        }
590
591        let frames = baked.len() / 2;
592        for frame in 0..frames {
593            let x = frame as f64 / MASTER_SAMPLE_RATE as f64;
594            let mut acc = [0.0f32; 2];
595            for cell in evaluator.cells() {
596                let [l, r] = cell_at(cell, x);
597                acc[0] += l;
598                acc[1] += r;
599            }
600            assert!(
601                (baked[frame * 2] - acc[0]).abs() < 1e-6
602                    && (baked[frame * 2 + 1] - acc[1]).abs() < 1e-6,
603                "frame {frame}: baked [{}, {}] vs walk {acc:?}",
604                baked[frame * 2],
605                baked[frame * 2 + 1]
606            );
607        }
608    }
609}