simple_editor\engine/
mixer.rs

1//! Audio mixer: sums every audible audio clip at timeline time t into interleaved stereo f32.
2//! Used by playback (real-time, block by block) and export (offline to WAV).
3//! Handles speed/reverse (linear resampling), freeze (silence), volume/pan/fades (gains lerped across
4//! each block), transitions (gain crossfades with virtual clip extension — on video tracks too, so a
5//! transition between Sequence clips crossfades their audio with the picture) and Sequence clips on
6//! video tracks (their timeline mixed recursively, depth ≤ 8).
7//!
8//! Routing: when the project has buses, every top-level clip's contribution lands in
9//! `Project::bus_of(track, clip)` instead of straight in the output, then `BusGraph` flushes the buses
10//! leaves-first (filters → gain/pan/mono → sum into the output bus) with Main summing into `out`.
11//! Bus mute/solo mirrors track mute/solo — any solo among the buses silences the un-soloed ones, except
12//! Main, which is the master everything sums through. Projects with no buses (the default) skip all of
13//! that and mix straight into `out`.
14
15use crate::engine::mixer_fx::BusGraph;
16use crate::media::{AudioSource, DecoderPool, SAMPLE_RATE};
17use crate::model::{Clip, ClipKind, Id, Project, Track, TrackKind, Transition};
18
19const MAX_DEPTH: usize = 8;
20
21/// Transition windows touching a clip: (cut time, clamped half-window, transition, clip is the right
22/// side). At most one per clip edge.
23type Ext<'a> = [Option<(f64, f64, &'a Transition, bool)>; 2];
24
25/// Where a clip's samples go: a plain buffer (no buses / a nested sequence sub-mix) or the bus graph.
26enum Dest<'a> {
27    Buf(&'a mut [f32]),
28    /// The graph plus the block length in frames (bus buffers are all that long).
29    Buses(&'a mut BusGraph, usize),
30}
31
32impl Dest<'_> {
33    fn frames(&self) -> usize {
34        match self {
35            Dest::Buf(b) => b.len() / 2,
36            Dest::Buses(_, f) => *f,
37        }
38    }
39    /// True when `slice`'s `bus` argument matters (only the top level routes).
40    fn routed(&self) -> bool {
41        matches!(self, Dest::Buses(..))
42    }
43    /// The [i0, i1) frame window of the buffer a clip on `bus` writes into.
44    fn slice(&mut self, bus: Id, i0: usize, i1: usize) -> &mut [f32] {
45        match self {
46            Dest::Buf(b) => &mut b[i0 * 2..i1 * 2],
47            Dest::Buses(g, frames) => &mut g.buffer(bus, *frames)[i0 * 2..i1 * 2],
48        }
49    }
50}
51
52/// Buffer pool, two per recursion depth: [2d] = source-read/resample buffer, [2d+1] = sequence
53/// sub-mix buffer. Grown once per size increase, never freed.
54#[derive(Default)]
55struct Scratch(Vec<Vec<f32>>);
56
57impl Scratch {
58    /// Take pool buffer `i`, zeroed and sized to `len` (capacity kept — grows once).
59    fn take(&mut self, i: usize, len: usize) -> Vec<f32> {
60        if self.0.len() <= i {
61            self.0.resize_with(i + 1, Vec::new);
62        }
63        let mut b = std::mem::take(&mut self.0[i]);
64        b.clear();
65        b.resize(len, 0.0);
66        b
67    }
68    fn put(&mut self, i: usize, b: Vec<f32>) {
69        self.0[i] = b;
70    }
71}
72
73#[derive(Default)]
74pub struct Mixer {
75    scratch: Scratch,
76    graph: BusGraph,
77    /// `graph.order()` copied once per block so the flush loop can hold `&mut graph`.
78    order: Vec<Id>,
79}
80
81impl Mixer {
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// The bus graph as of the last `mix` — the mixer panel reads its meters from here.
87    pub fn graph(&self) -> &BusGraph {
88        &self.graph
89    }
90
91    /// Mix into `out` (interleaved stereo, frames = out.len()/2) starting at timeline time `t`.
92    /// `out` is zeroed first. For each audio track with `project.active(track)`, each enabled clip
93    /// overlapping [t, t + frames/48000): read `pool.audio(asset.path, clip.audio_stream)` at
94    /// `clip.src_time(..)` for the overlapping sub-range, apply `clip.volume` (ramped linearly from the
95    /// value at the block start to the block end), add into the clip's bus. Buses are then flushed in
96    /// evaluation order and the result clamped to [-1, 1].
97    pub fn mix(&mut self, project: &Project, t: f64, pool: &mut DecoderPool, out: &mut [f32]) {
98        out.fill(0.0);
99        if out.len() < 2 {
100            return;
101        }
102        let Mixer { scratch, graph, order } = self;
103        if project.buses.is_empty() {
104            mix_tracks(scratch, project, &project.tracks, t, pool, &mut Dest::Buf(out), 0);
105        } else {
106            let frames = out.len() / 2;
107            graph.sync(project);
108            graph.begin(frames);
109            mix_tracks(scratch, project, &project.tracks, t, pool, &mut Dest::Buses(graph, frames), 0);
110            order.clear();
111            order.extend_from_slice(graph.order_ref());
112            for &id in order.iter() {
113                if let Some(bus) = project.bus(id) {
114                    graph.flush(bus, t, out);
115                }
116            }
117        }
118        for s in out.iter_mut() {
119            *s = s.clamp(-1.0, 1.0);
120        }
121    }
122}
123
124/// Accumulate (no zeroing, no clamping) the audio of `tracks` over [t, t + dest.frames() frames).
125fn mix_tracks(
126    scratch: &mut Scratch,
127    project: &Project,
128    tracks: &[Track],
129    t: f64,
130    pool: &mut DecoderPool,
131    dest: &mut Dest,
132    depth: usize,
133) {
134    let frames = dest.frames();
135    if frames == 0 {
136        return;
137    }
138    let sr = SAMPLE_RATE as f64;
139    let t_end = t + frames as f64 / sr;
140    for (ti, track) in tracks.iter().enumerate() {
141        if !active_in(tracks, ti) {
142            continue;
143        }
144        match track.kind {
145            TrackKind::Audio => {
146                for clip in &track.clips {
147                    if !clip.enabled || clip.kind != ClipKind::Audio || clip.freeze.is_some() {
148                        continue;
149                    }
150                    let ext = clip_transitions(track, clip);
151                    let (estart, eend) = play_range(clip, &ext);
152                    if eend <= t || estart >= t_end {
153                        continue;
154                    }
155                    let Some(asset) = project.asset(clip.asset) else {
156                        continue;
157                    };
158                    let i0 = (((estart.max(t) - t) * sr).round() as usize).min(frames);
159                    let i1 = (((eend.min(t_end) - t) * sr).round() as usize).min(frames);
160                    if i1 <= i0 {
161                        continue;
162                    }
163                    let bus = if dest.routed() { project.bus_of(ti, clip) } else { 0 };
164                    let t0 = t + i0 as f64 / sr;
165                    mix_audio_clip(scratch, clip, &ext, t0, &asset.path, pool, dest.slice(bus, i0, i1), depth);
166                }
167            }
168            TrackKind::Video => {
169                for clip in &track.clips {
170                    if !clip.enabled || clip.kind != ClipKind::Sequence || clip.freeze.is_some() || depth >= MAX_DEPTH {
171                        continue;
172                    }
173                    let ext = clip_transitions(track, clip);
174                    let (estart, eend) = play_range(clip, &ext);
175                    if eend <= t || estart >= t_end {
176                        continue;
177                    }
178                    let i0 = (((estart.max(t) - t) * sr).round() as usize).min(frames);
179                    let i1 = (((eend.min(t_end) - t) * sr).round() as usize).min(frames);
180                    if i1 <= i0 || project.sequence_tracks(clip.sequence).is_none() {
181                        continue;
182                    }
183                    let bus = if dest.routed() { project.bus_of(ti, clip) } else { 0 };
184                    mix_seq_clip(
185                        scratch,
186                        project,
187                        clip,
188                        &ext,
189                        t + i0 as f64 / sr,
190                        pool,
191                        dest.slice(bus, i0, i1),
192                        depth,
193                    );
194                }
195            }
196        }
197    }
198}
199
200/// One audio clip: read ceil(n*speed)+1 source frames at the clip's source time, linearly resample
201/// into `out` (n frames) and add with per-channel gains lerped from the block start to the block end.
202#[allow(clippy::too_many_arguments)]
203fn mix_audio_clip(
204    scratch: &mut Scratch,
205    clip: &Clip,
206    ext: &Ext,
207    t0: f64,
208    path: &str,
209    pool: &mut DecoderPool,
210    out: &mut [f32],
211    depth: usize,
212) {
213    let n = out.len() / 2;
214    let m = (n as f64 * clip.speed).ceil() as usize + 1;
215    let mut buf = scratch.take(depth * 2, m * 2);
216    let sr = SAMPLE_RATE as f64;
217    let s0 = if clip.reverse {
218        // the source block ends at src_time(t0) and is walked backwards
219        clip.src_time(t0) - (m - 1) as f64 / sr
220    } else {
221        clip.src_time(t0)
222    };
223    if let Some(src) = pool.audio(path, clip.audio_stream) {
224        read_block(src, s0, &mut buf);
225        resample_add(clip, ext, t0, &buf, out);
226    }
227    scratch.put(depth * 2, buf);
228}
229
230/// One Sequence clip on a video track: recursively mix its sequence's tracks at source rate into a
231/// scratch buffer, then treat that buffer exactly like clip source audio (resample + gains).
232/// Nested tracks keep their own mute/solo but not their own buses — the whole sub-mix goes to the
233/// bus of the Sequence clip that hosts it.
234#[allow(clippy::too_many_arguments)]
235fn mix_seq_clip(
236    scratch: &mut Scratch,
237    project: &Project,
238    clip: &Clip,
239    ext: &Ext,
240    t0: f64,
241    pool: &mut DecoderPool,
242    out: &mut [f32],
243    depth: usize,
244) {
245    let n = out.len() / 2;
246    let m = (n as f64 * clip.speed).ceil() as usize + 1;
247    let mut buf = scratch.take(depth * 2 + 1, m * 2);
248    let sr = SAMPLE_RATE as f64;
249    let s0 = if clip.reverse { clip.src_time(t0) - (m - 1) as f64 / sr } else { clip.src_time(t0) };
250    if let Some(tracks) = project.sequence_tracks(clip.sequence) {
251        mix_tracks(scratch, project, tracks, s0, pool, &mut Dest::Buf(&mut buf), depth + 1);
252        resample_add(clip, ext, t0, &buf, out);
253    }
254    scratch.put(depth * 2 + 1, buf);
255}
256
257/// Mute/solo resolution over an arbitrary track list (same rule as `Project::active`, which only knows
258/// the top-level tracks).
259fn active_in(tracks: &[Track], i: usize) -> bool {
260    let t = &tracks[i];
261    let any_solo = tracks.iter().any(|o| o.kind == t.kind && o.solo);
262    if any_solo {
263        t.solo
264    } else {
265        !t.muted
266    }
267}
268
269/// The (still valid) transitions whose window this clip plays in (windows clamped to the cut's clips,
270/// so an over-long transition cannot drag a clip past its neighbours — same rule as the compositor).
271fn clip_transitions<'a>(track: &'a Track, clip: &Clip) -> Ext<'a> {
272    let mut ext = [None, None];
273    for tr in &track.transitions {
274        let Some((l, r)) = track.transition_clips(tr) else { continue };
275        let Some((cut, h)) = tr.cut_half(l, r) else { continue };
276        // right side gains in (a cut's incoming clip, or an In edge), left side gains out
277        if r.is_some_and(|r| r.id == clip.id) {
278            ext[0] = Some((cut, h, tr, true));
279        } else if l.is_some_and(|l| l.id == clip.id) {
280            ext[1] = Some((cut, h, tr, false));
281        }
282    }
283    ext
284}
285
286/// The clip's audible timeline range: its own extent, virtually extended into transition windows.
287fn play_range(clip: &Clip, ext: &Ext) -> (f64, f64) {
288    let mut s = clip.start;
289    let mut e = clip.end();
290    if let Some((cut, h, ..)) = ext[0] {
291        s = s.min(cut - h);
292    }
293    if let Some((cut, h, ..)) = ext[1] {
294        e = e.max(cut + h);
295    }
296    (s, e)
297}
298
299/// (left, right) gains at absolute time tt: volume × fade in/out × transition crossfade, panned.
300/// Clip-local time is clamped into the clip for volume/pan/fades so virtual extensions hold the edge value.
301fn gains(clip: &Clip, ext: &Ext, tt: f64) -> (f32, f32) {
302    let lt = clip.local(tt).clamp(0.0, clip.duration);
303    let mut g = clip.volume.at(lt) as f32 * clip.fade_mult(lt) as f32;
304    for e in ext.iter().flatten() {
305        let (cut, h, tr, is_right) = *e;
306        if tt >= cut - h && tt < cut + h {
307            let p = crate::engine::compose::trans_progress_at(tr, cut, h, tt) as f32;
308            g *= if is_right { p } else { 1.0 - p };
309        }
310    }
311    let pan = clip.pan.at(lt).clamp(-1.0, 1.0) as f32;
312    (g * (1.0 - pan).min(1.0), g * (1.0 + pan).min(1.0))
313}
314
315/// Fill `buf` from `src` starting at source time `s0`; time before 0 is silence.
316fn read_block(src: &mut dyn AudioSource, s0: f64, buf: &mut [f32]) {
317    if s0 >= 0.0 {
318        src.read_at(s0, buf);
319        return;
320    }
321    let skip = (((-s0) * SAMPLE_RATE as f64).round() as usize) * 2;
322    if skip >= buf.len() {
323        buf.fill(0.0);
324        return;
325    }
326    buf[..skip].fill(0.0);
327    src.read_at(0.0, &mut buf[skip..]);
328}
329
330/// Linearly resample `buf` (m source frames read at the clip's source time for `t0`) into `out`
331/// (n frames) and add with gains lerped across the block.
332/// ponytail: gains (volume keys, fades, transition ease) are sampled at the block ends and lerped —
333/// exact for linear ramps, ≤ one block of shape error otherwise; split at kinks if it matters. Both
334/// callers use 1024-frame blocks (≈21 ms: `playback::BLOCK` and `export::MIX_BLOCK`), so playback and
335/// export shape a fade identically.
336fn resample_add(clip: &Clip, ext: &Ext, t0: f64, buf: &[f32], out: &mut [f32]) {
337    let n = out.len() / 2;
338    let m = buf.len() / 2;
339    if n == 0 || m == 0 {
340        return;
341    }
342    let (l0, r0) = gains(clip, ext, t0);
343    let (l1, r1) = gains(clip, ext, t0 + n as f64 / SAMPLE_RATE as f64);
344    let dl = (l1 - l0) / n as f32;
345    let dr = (r1 - r0) / n as f32;
346    let last = m - 1;
347    for (k, o) in out.chunks_exact_mut(2).enumerate() {
348        let pos = if clip.reverse { last as f64 - k as f64 * clip.speed } else { k as f64 * clip.speed };
349        let pos = pos.max(0.0);
350        let j = (pos as usize).min(last);
351        let j1 = (j + 1).min(last);
352        let fr = (pos - j as f64) as f32;
353        let a = &buf[j * 2..j * 2 + 2];
354        let b = &buf[j1 * 2..j1 * 2 + 2];
355        o[0] += (a[0] + (b[0] - a[0]) * fr) * (l0 + dl * k as f32);
356        o[1] += (a[1] + (b[1] - a[1]) * fr) * (r0 + dr * k as f32);
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::media::{AudioSource, Backend};
364    use crate::model::{Asset, AudioStreamInfo, ClipKind, Ease, TransitionKind};
365
366    struct Const(f32);
367    impl AudioSource for Const {
368        fn duration(&self) -> f64 {
369            10.0
370        }
371        fn read_at(&mut self, t: f64, out: &mut [f32]) {
372            for (i, s) in out.iter_mut().enumerate() {
373                let tt = t + (i / 2) as f64 / SAMPLE_RATE as f64;
374                *s = if (0.0..10.0).contains(&tt) { self.0 } else { 0.0 };
375            }
376        }
377    }
378
379    /// Sample value == source time × 0.01 (stays inside [-1, 1] for 10 s media).
380    struct Ramp;
381    impl AudioSource for Ramp {
382        fn duration(&self) -> f64 {
383            10.0
384        }
385        fn read_at(&mut self, t: f64, out: &mut [f32]) {
386            for (i, s) in out.iter_mut().enumerate() {
387                let tt = t + (i / 2) as f64 / SAMPLE_RATE as f64;
388                *s = if (0.0..10.0).contains(&tt) { (tt * 0.01) as f32 } else { 0.0 };
389            }
390        }
391    }
392
393    fn audio_asset(id: crate::model::Id, path: &str) -> Asset {
394        Asset {
395            id,
396            path: path.into(),
397            kind: ClipKind::Audio,
398            duration: 10.0,
399            width: 0,
400            height: 0,
401            fps: 0.0,
402            audio_streams: vec![AudioStreamInfo { index: 0, channels: 2, sample_rate: 48000, ..Default::default() }],
403            codec: "pcm".into(),
404            folder: String::new(),
405            tags: Vec::new(),
406            label: 0,
407            description: String::new(),
408        }
409    }
410
411    fn project() -> Project {
412        Project::from_media(audio_asset(0, "Z:\\nope\\fake.wav"))
413    }
414
415    fn pool() -> DecoderPool {
416        let mut p = DecoderPool::new(Backend::Ffmpeg);
417        p.insert_audio("Z:\\nope\\fake.wav", 0, Box::new(Const(0.5)));
418        p
419    }
420
421    #[test]
422    fn volume_mute_solo() {
423        let mut p = project();
424        let ai = p.audio_tracks()[0];
425        p.tracks[ai].clips[0].volume.value = 0.5;
426        let mut pool = pool();
427        let mut mx = Mixer::new();
428        let mut out = vec![1.0f32; 2048];
429        mx.mix(&p, 1.0, &mut pool, &mut out);
430        assert!(out.iter().all(|s| (s - 0.25).abs() < 1e-5), "{:?}", &out[..4]);
431
432        // muted track → silence
433        p.tracks[ai].muted = true;
434        mx.mix(&p, 1.0, &mut pool, &mut out);
435        assert!(out.iter().all(|s| *s == 0.0));
436        p.tracks[ai].muted = false;
437
438        // solo on another audio track silences this one; solo on this one keeps it
439        let other = p.add_track(TrackKind::Audio);
440        p.tracks[other].solo = true;
441        mx.mix(&p, 1.0, &mut pool, &mut out);
442        assert!(out.iter().all(|s| *s == 0.0));
443        p.tracks[ai].solo = true;
444        mx.mix(&p, 1.0, &mut pool, &mut out);
445        assert!(out.iter().all(|s| (s - 0.25).abs() < 1e-5));
446
447        // outside the clip → silence; clip ending mid-block → partial
448        mx.mix(&p, 20.0, &mut pool, &mut out);
449        assert!(out.iter().all(|s| *s == 0.0));
450        let mut out = vec![0.0f32; 96]; // 48 frames = 1 ms
451        mx.mix(&p, 10.0 - 0.0005, &mut pool, &mut out);
452        assert!((out[0] - 0.25).abs() < 1e-5);
453        assert_eq!(out[95], 0.0);
454    }
455
456    #[test]
457    fn bus_routing() {
458        use crate::model::{AudioFilter, FilterKind};
459        let mut p = project();
460        let ai = p.audio_tracks()[0];
461        p.main_bus();
462        let a = p.add_bus("A");
463        p.tracks[ai].bus = a;
464        let mut pool = pool();
465        let mut mx = Mixer::new();
466        let mut out = vec![0.0f32; 2 * 480];
467
468        // A → Main with unity gain: the source comes through untouched
469        mx.mix(&p, 1.0, &mut pool, &mut out);
470        assert!(out.iter().all(|s| (s - 0.5).abs() < 1e-5), "{}", out[0]);
471
472        // a clip on a muted bus is silent
473        p.bus_mut(a).unwrap().muted = true;
474        mx.mix(&p, 1.0, &mut pool, &mut out);
475        assert!(out.iter().all(|s| *s == 0.0), "muted bus: {}", out[0]);
476        p.bus_mut(a).unwrap().muted = false;
477
478        // a solo elsewhere mutes A; soloing A brings it back (Main is never solo-silenced)
479        let b = p.add_bus("B");
480        p.bus_mut(b).unwrap().solo = true;
481        mx.mix(&p, 1.0, &mut pool, &mut out);
482        assert!(out.iter().all(|s| *s == 0.0), "other bus soloed: {}", out[0]);
483        p.bus_mut(a).unwrap().solo = true;
484        mx.mix(&p, 1.0, &mut pool, &mut out);
485        assert!(out.iter().all(|s| (s - 0.5).abs() < 1e-5), "A soloed: {}", out[0]);
486        p.bus_mut(a).unwrap().solo = false;
487        p.bus_mut(b).unwrap().solo = false;
488
489        // bus gain and pan
490        p.bus_mut(a).unwrap().gain.value = 0.5;
491        mx.mix(&p, 1.0, &mut pool, &mut out);
492        assert!(out.iter().all(|s| (s - 0.25).abs() < 1e-5), "bus gain: {}", out[0]);
493        p.bus_mut(a).unwrap().gain.value = 1.0;
494
495        // mono folds L and R: pan the clip hard left, then fold
496        p.tracks[ai].clips[0].pan.value = -1.0;
497        mx.mix(&p, 1.0, &mut pool, &mut out);
498        assert!((out[0] - 0.5).abs() < 1e-5 && out[1].abs() < 1e-5, "{:?}", &out[..2]);
499        p.bus_mut(a).unwrap().mono = true;
500        mx.mix(&p, 1.0, &mut pool, &mut out);
501        assert!(out.iter().all(|s| (s - 0.25).abs() < 1e-5), "mono: {:?}", &out[..2]);
502        p.bus_mut(a).unwrap().mono = false;
503        p.tracks[ai].clips[0].pan.value = 0.0;
504
505        // the bus filter chain runs on the summed bus
506        let mut f = AudioFilter::new(FilterKind::Gain);
507        f.params[0].value = -6.0;
508        p.bus_mut(a).unwrap().filters.push(f);
509        mx.mix(&p, 1.0, &mut pool, &mut out);
510        let want = 0.5 * crate::engine::mixer_fx::db_to_lin(-6.0);
511        assert!(out.iter().all(|s| (s - want).abs() < 1e-4), "bus filter: {} vs {want}", out[0]);
512        p.bus_mut(a).unwrap().filters.clear();
513
514        // a clip override beats its track's bus
515        p.bus_mut(a).unwrap().muted = true;
516        p.tracks[ai].clips[0].bus = b;
517        mx.mix(&p, 1.0, &mut pool, &mut out);
518        assert!(out.iter().all(|s| (s - 0.5).abs() < 1e-5), "clip override: {}", out[0]);
519
520        // removing that bus clears the override, so the clip inherits its (still muted) track bus…
521        p.remove_bus(b);
522        mx.mix(&p, 1.0, &mut pool, &mut out);
523        assert!(out.iter().all(|s| *s == 0.0), "back to the muted track bus: {}", out[0]);
524        // …and clearing the track bus falls back to Main
525        p.tracks[ai].bus = 0;
526        mx.mix(&p, 1.0, &mut pool, &mut out);
527        assert!(out.iter().all(|s| (s - 0.5).abs() < 1e-5), "fallback to Main: {}", out[0]);
528    }
529
530    #[test]
531    fn bus_chain_sums_into_main() {
532        // A → B → Main: the deeper bus must be flushed first, and its gain must apply on the way.
533        let mut p = project();
534        let ai = p.audio_tracks()[0];
535        p.main_bus();
536        let a = p.add_bus("A");
537        let b = p.add_bus("B");
538        p.bus_mut(a).unwrap().output = b;
539        p.bus_mut(b).unwrap().gain.value = 0.5;
540        p.tracks[ai].bus = a;
541        let mut pool = pool();
542        let mut mx = Mixer::new();
543        let mut out = vec![0.0f32; 2 * 480];
544        mx.mix(&p, 1.0, &mut pool, &mut out);
545        assert!(out.iter().all(|s| (s - 0.25).abs() < 1e-5), "{}", out[0]);
546        assert!((mx.graph().meter(a).0 - 0.5).abs() < 1e-4, "A meters pre-B: {:?}", mx.graph().meter(a));
547        assert!((mx.graph().meter(b).0 - 0.25).abs() < 1e-4, "{:?}", mx.graph().meter(b));
548    }
549
550    #[test]
551    fn ramp_and_clamp() {
552        let mut p = project();
553        let ai = p.audio_tracks()[0];
554        let c = &mut p.tracks[ai].clips[0];
555        c.volume.keys = vec![
556            crate::model::Keyframe { t: 0.0, v: 0.0, ease: Ease::Linear },
557            crate::model::Keyframe { t: 1.0, v: 2.0, ease: Ease::Linear },
558        ];
559        let mut pool = pool();
560        let mut mx = Mixer::new();
561        let mut out = vec![0.0f32; 2 * 4800]; // 100 ms block from t=0 → volume 0 → 0.2 → samples 0 → 0.1
562        mx.mix(&p, 0.0, &mut pool, &mut out);
563        assert!(out[0].abs() < 1e-5);
564        assert!((out[out.len() - 2] - 0.1).abs() < 1e-3, "{}", out[out.len() - 2]);
565        assert!(out[2400 * 2] > out[1200 * 2]);
566        // volume 2 → 1.0 → clamped at 1.0 on a 0.5 source? 0.5*2 = 1.0 exactly; use 4x to force clamp
567        p.tracks[ai].clips[0].volume = crate::model::Animated::new(4.0);
568        mx.mix(&p, 0.5, &mut pool, &mut out);
569        assert!(out.iter().all(|s| *s == 1.0));
570    }
571
572    #[test]
573    fn speed_reverse_freeze() {
574        let mut p = project();
575        let ai = p.audio_tracks()[0];
576        let mut pool = DecoderPool::new(Backend::Ffmpeg);
577        pool.insert_audio("Z:\\nope\\fake.wav", 0, Box::new(Ramp));
578        let mut mx = Mixer::new();
579        let mut out = vec![0.0f32; 2 * 480]; // 10 ms
580        let sr = SAMPLE_RATE as f64;
581
582        // speed 2: at t=1 the source time is 2, advancing 2× per output frame
583        p.tracks[ai].clips[0].set_speed(2.0);
584        assert!((p.tracks[ai].clips[0].duration - 5.0).abs() < 1e-9);
585        mx.mix(&p, 1.0, &mut pool, &mut out);
586        assert!((out[0] - 0.02).abs() < 1e-4, "{}", out[0]);
587        let k = 400;
588        let want = (2.0 + 2.0 * k as f64 / sr) * 0.01;
589        assert!((out[k * 2] as f64 - want).abs() < 1e-4, "{} vs {want}", out[k * 2]);
590
591        // reverse at speed 1: src_time(t) = 10 - t → decreasing ramp
592        let c = &mut p.tracks[ai].clips[0];
593        c.set_speed(1.0);
594        c.reverse = true;
595        mx.mix(&p, 1.0, &mut pool, &mut out);
596        assert!((out[0] - 0.09).abs() < 1e-4, "{}", out[0]);
597        let want = (9.0 - k as f64 / sr) * 0.01;
598        assert!((out[k * 2] as f64 - want).abs() < 1e-4);
599        assert!(out[0] > out[k * 2]);
600
601        // freeze → silence
602        p.tracks[ai].clips[0].freeze = Some(3.0);
603        mx.mix(&p, 1.0, &mut pool, &mut out);
604        assert!(out.iter().all(|s| *s == 0.0));
605    }
606
607    #[test]
608    fn pan_and_fades() {
609        let mut p = project();
610        let ai = p.audio_tracks()[0];
611        let mut pool = pool();
612        let mut mx = Mixer::new();
613        let mut out = vec![0.0f32; 2 * 480];
614
615        // pan 0.5 → L × 0.5, R × 1
616        p.tracks[ai].clips[0].pan.value = 0.5;
617        mx.mix(&p, 1.0, &mut pool, &mut out);
618        assert!((out[0] - 0.25).abs() < 1e-5, "{}", out[0]);
619        assert!((out[1] - 0.5).abs() < 1e-5, "{}", out[1]);
620        // pan -1 → L × 1, R × 0
621        p.tracks[ai].clips[0].pan.value = -1.0;
622        mx.mix(&p, 1.0, &mut pool, &mut out);
623        assert!((out[0] - 0.5).abs() < 1e-5);
624        assert!(out[1].abs() < 1e-5);
625        p.tracks[ai].clips[0].pan.value = 0.0;
626
627        // fade_in 2 s: gain 0.5 at t=1; fade_out 2 s: gain 0.25 at t=9.5 (clip is 10 s)
628        let c = &mut p.tracks[ai].clips[0];
629        c.fade_in = 2.0;
630        c.fade_out = 2.0;
631        mx.mix(&p, 1.0, &mut pool, &mut out);
632        assert!((out[0] - 0.25).abs() < 1e-3, "{}", out[0]);
633        mx.mix(&p, 9.5, &mut pool, &mut out);
634        assert!((out[0] - 0.125).abs() < 1e-3, "{}", out[0]);
635        // edges are silent
636        mx.mix(&p, 0.0, &mut pool, &mut out);
637        assert!(out[0].abs() < 1e-3);
638    }
639
640    /// A fade-in at the very start of playback (block-sized like `playback::BLOCK`) must not bleed full
641    /// volume: sample 0 is near-silent and every following sample in the block is >= the one before it.
642    #[test]
643    fn fade_in_first_block_near_silent_and_rising() {
644        let mut p = project();
645        let ai = p.audio_tracks()[0];
646        p.tracks[ai].clips[0].fade_in = 0.5;
647        let mut pool = pool();
648        let mut mx = Mixer::new();
649        let mut out = vec![0.0f32; 2 * 1024]; // playback::BLOCK
650        mx.mix(&p, 0.0, &mut pool, &mut out);
651        assert!(out[0].abs() < 1e-4, "sample 0 not near-silent: {}", out[0]);
652        let mut prev = -1.0f32;
653        for s in out.chunks_exact(2).map(|s| s[0]) {
654            assert!(s + 1e-6 >= prev, "gain dipped: {s} after {prev}");
655            prev = s;
656        }
657    }
658
659    #[test]
660    fn transition_crossfade() {
661        // A1: Const(0.8) on [0,5) then Const(0.4) on [5,10), CrossFade of 2 s at the cut.
662        let mut p = Project::new();
663        let a = audio_asset(0, "Z:\\nope\\a.wav");
664        let b = audio_asset(0, "Z:\\nope\\b.wav");
665        let a = p.add_asset(a);
666        let b = p.add_asset(b);
667        let ai = p.audio_tracks()[0];
668        let mut c1 = Clip::new(1000, ClipKind::Audio, "a", 0.0, 5.0);
669        c1.asset = a;
670        let mut c2 = Clip::new(1001, ClipKind::Audio, "b", 5.0, 5.0);
671        c2.asset = b;
672        c2.src_in = 5.0; // pre-roll into the transition window reads source [4, 5)
673        let right = c2.id;
674        p.tracks[ai].clips.push(c1);
675        p.tracks[ai].clips.push(c2);
676        p.tracks[ai].transitions.push(Transition {
677            id: 2000,
678            right,
679            kind: TransitionKind::CrossFade,
680            duration: 2.0,
681            color: [0, 0, 0, 255],
682            direction: 0,
683            ease: Ease::Linear,
684            edge: Default::default(),
685        });
686        let mut pool = DecoderPool::new(Backend::Ffmpeg);
687        pool.insert_audio("Z:\\nope\\a.wav", 0, Box::new(Const(0.8)));
688        pool.insert_audio("Z:\\nope\\b.wav", 0, Box::new(Const(0.4)));
689        let mut mx = Mixer::new();
690        let mut out = vec![0.0f32; 2 * 480];
691        // outside the window
692        mx.mix(&p, 2.0, &mut pool, &mut out);
693        assert!((out[0] - 0.8).abs() < 1e-4, "{}", out[0]);
694        mx.mix(&p, 8.0, &mut pool, &mut out);
695        assert!((out[0] - 0.4).abs() < 1e-4);
696        // window is [4, 6): p=0.25 at 4.5 → 0.8·0.75 + 0.4·0.25 = 0.7 (right clip plays before its start)
697        mx.mix(&p, 4.5, &mut pool, &mut out);
698        assert!((out[0] - 0.7).abs() < 1e-3, "{}", out[0]);
699        // p=0.75 at 5.5 → 0.8·0.25 + 0.4·0.75 = 0.5 (left clip extended past its end)
700        mx.mix(&p, 5.5, &mut pool, &mut out);
701        assert!((out[0] - 0.5).abs() < 1e-3, "{}", out[0]);
702        // exactly at the cut: p=0.5 → 0.6
703        mx.mix(&p, 5.0, &mut pool, &mut out);
704        assert!((out[0] - 0.6).abs() < 1e-3, "{}", out[0]);
705    }
706
707    /// A transition of `dur` on the cut between two clips already pushed on `ti`.
708    fn add_transition(p: &mut Project, ti: usize, right: crate::model::Id, dur: f64) {
709        p.tracks[ti].transitions.push(Transition {
710            id: 2000,
711            right,
712            kind: TransitionKind::CrossFade,
713            duration: dur,
714            color: [0, 0, 0, 255],
715            direction: 0,
716            ease: Ease::Linear,
717            edge: Default::default(),
718        });
719    }
720
721    #[test]
722    fn transition_window_clamped_to_clips() {
723        // A [0,5) then a 1 s B, with an 8 s transition: the window is clamped to B → [4,6), so nothing
724        // plays after 6 (an unclamped window would extend both clips out to 9).
725        let mut p = Project::new();
726        let a = p.add_asset(audio_asset(0, "Z:\\nope\\a.wav"));
727        let b = p.add_asset(audio_asset(0, "Z:\\nope\\b.wav"));
728        let ai = p.audio_tracks()[0];
729        let mut c1 = Clip::new(1000, ClipKind::Audio, "a", 0.0, 5.0);
730        c1.asset = a;
731        let mut c2 = Clip::new(1001, ClipKind::Audio, "b", 5.0, 1.0);
732        c2.asset = b;
733        c2.src_in = 5.0;
734        let right = c2.id;
735        p.tracks[ai].clips.push(c1);
736        p.tracks[ai].clips.push(c2);
737        add_transition(&mut p, ai, right, 8.0);
738        let mut pool = DecoderPool::new(Backend::Ffmpeg);
739        pool.insert_audio("Z:\\nope\\a.wav", 0, Box::new(Const(0.8)));
740        pool.insert_audio("Z:\\nope\\b.wav", 0, Box::new(Const(0.4)));
741        let mut mx = Mixer::new();
742        let mut out = vec![0.0f32; 2 * 480];
743        mx.mix(&p, 2.0, &mut pool, &mut out);
744        assert!((out[0] - 0.8).abs() < 1e-4, "before the clamped window: {}", out[0]);
745        mx.mix(&p, 5.0, &mut pool, &mut out);
746        assert!((out[0] - 0.6).abs() < 1e-3, "at the cut: {}", out[0]);
747        mx.mix(&p, 7.0, &mut pool, &mut out);
748        assert!(out.iter().all(|s| *s == 0.0), "audio past both clips: {}", out[0]);
749    }
750
751    #[test]
752    fn sequence_transition_crossfades_audio() {
753        // Two sequence clips on V1 with a 2 s CrossFade: their audio must dissolve with the picture.
754        let mut p = Project::new();
755        let mut seq = Vec::new();
756        for (i, path) in ["Z:\\nope\\a.wav", "Z:\\nope\\b.wav"].into_iter().enumerate() {
757            let asset = p.add_asset(audio_asset(0, path));
758            let s = p.new_sequence("s", 320, 240, 30.0);
759            let sq = p.sequence_mut(s).unwrap();
760            let sai = sq.tracks.iter().position(|t| t.kind == TrackKind::Audio).unwrap();
761            let mut inner = Clip::new(500 + i as crate::model::Id, ClipKind::Audio, "in", 0.0, 10.0);
762            inner.asset = asset;
763            sq.tracks[sai].clips.push(inner);
764            seq.push(s);
765        }
766        let vi = p.video_tracks()[0];
767        for (i, s) in seq.iter().enumerate() {
768            let mut c = Clip::new(1000 + i as crate::model::Id, ClipKind::Sequence, "sc", i as f64 * 5.0, 5.0);
769            c.sequence = *s;
770            c.src_in = i as f64 * 5.0;
771            p.tracks[vi].clips.push(c);
772        }
773        add_transition(&mut p, vi, 1001, 2.0);
774        let mut pool = DecoderPool::new(Backend::Ffmpeg);
775        pool.insert_audio("Z:\\nope\\a.wav", 0, Box::new(Const(0.8)));
776        pool.insert_audio("Z:\\nope\\b.wav", 0, Box::new(Const(0.4)));
777        let mut mx = Mixer::new();
778        let mut out = vec![0.0f32; 2 * 480];
779        // window [4,6): 0.25 in → 0.8·0.75 + 0.4·0.25 = 0.7 (was a hard cut at t=5)
780        mx.mix(&p, 4.5, &mut pool, &mut out);
781        assert!((out[0] - 0.7).abs() < 1e-3, "{}", out[0]);
782        mx.mix(&p, 5.5, &mut pool, &mut out);
783        assert!((out[0] - 0.5).abs() < 1e-3, "{}", out[0]);
784        // outside the window each sequence plays alone
785        mx.mix(&p, 2.0, &mut pool, &mut out);
786        assert!((out[0] - 0.8).abs() < 1e-4, "{}", out[0]);
787        mx.mix(&p, 8.0, &mut pool, &mut out);
788        assert!((out[0] - 0.4).abs() < 1e-4, "{}", out[0]);
789    }
790
791    #[test]
792    fn sequence_audio() {
793        // Sequence with a Ramp audio clip [0,4); placed on V1 at t=1.
794        let mut p = Project::new();
795        let aid = p.add_asset(audio_asset(0, "Z:\\nope\\fake.wav"));
796        let seq = p.new_sequence("s", 320, 240, 30.0);
797        let mut inner = Clip::new(500, ClipKind::Audio, "in", 0.0, 4.0);
798        inner.asset = aid;
799        let s = p.sequence_mut(seq).unwrap();
800        let sai = s.tracks.iter().position(|t| t.kind == TrackKind::Audio).unwrap();
801        s.tracks[sai].clips.push(inner);
802        let sc = p.insert_sequence_clip(seq, 1.0, None).expect("placed");
803        let mut pool = DecoderPool::new(Backend::Ffmpeg);
804        pool.insert_audio("Z:\\nope\\fake.wav", 0, Box::new(Ramp));
805        let mut mx = Mixer::new();
806        let mut out = vec![0.0f32; 2 * 480];
807        // t=1.5 → sequence-local 0.5 → ramp value 0.005
808        mx.mix(&p, 1.5, &mut pool, &mut out);
809        assert!((out[0] - 0.005).abs() < 1e-4, "{}", out[0]);
810        // clip volume/pan apply
811        {
812            let c = p.clip_mut(sc).unwrap();
813            c.volume.value = 0.5;
814            c.pan.value = 1.0;
815        }
816        mx.mix(&p, 1.5, &mut pool, &mut out);
817        assert!(out[0].abs() < 1e-5, "L muted by pan, got {}", out[0]);
818        assert!((out[1] - 0.0025).abs() < 1e-4, "{}", out[1]);
819        {
820            let c = p.clip_mut(sc).unwrap();
821            c.volume.value = 1.0;
822            c.pan.value = 0.0;
823        }
824        // speed 2 on the sequence clip: at t=1.5 sequence-local source time = 1.0
825        p.clip_mut(sc).unwrap().set_speed(2.0);
826        mx.mix(&p, 1.5, &mut pool, &mut out);
827        assert!((out[0] - 0.01).abs() < 1e-4, "{}", out[0]);
828        p.clip_mut(sc).unwrap().set_speed(1.0);
829        // before the sequence clip → silence; frozen → silence; hidden video track → silence
830        mx.mix(&p, 0.5, &mut pool, &mut out);
831        assert!(out.iter().all(|s| *s == 0.0));
832        p.clip_mut(sc).unwrap().freeze = Some(1.0);
833        mx.mix(&p, 1.5, &mut pool, &mut out);
834        assert!(out.iter().all(|s| *s == 0.0));
835        p.clip_mut(sc).unwrap().freeze = None;
836        let vi = p.video_tracks()[0];
837        p.tracks[vi].muted = true;
838        mx.mix(&p, 1.5, &mut pool, &mut out);
839        assert!(out.iter().all(|s| *s == 0.0));
840    }
841}