Skip to main content

motion_core/authoring/timeline/
soundtrack.rs

1//! Audio owned by a timeline: placed clips, named submixes and master processing.
2
3use super::invalid;
4use crate::audio::{AudioBus, Master};
5use crate::{AudioClip, Duration, FrameRate};
6use std::sync::Arc;
7
8const MAX_BUSES: usize = 16;
9
10/// Audio clips, buses and master settings of one timeline.
11///
12/// Audio is not snapped to the video grid, may overlap freely and is grouped by
13/// bus, so it is kept apart from the picture tracks. Clones share storage.
14#[derive(Clone, Debug, Default)]
15pub struct Soundtrack {
16    pub(super) clips: Arc<Vec<AudioClip>>,
17    pub(super) buses: Arc<Vec<AudioBus>>,
18    pub(super) master: Master,
19}
20
21impl Soundtrack {
22    /// Clips declared on this timeline, in insertion order; nested timelines
23    /// contribute their audio when flattened for mixing.
24    pub fn clips(&self) -> &[AudioClip] {
25        &self.clips
26    }
27
28    /// Declared submixes in deterministic insertion order.
29    pub fn buses(&self) -> &[AudioBus] {
30        &self.buses
31    }
32
33    /// Master settings applied after all buses.
34    pub fn master(&self) -> Master {
35        self.master
36    }
37
38    pub(super) fn validate(&self) -> crate::Result<()> {
39        self.master.validate()?;
40        if self.buses.len() > MAX_BUSES {
41            return Err(invalid("At most sixteen audio buses"));
42        }
43        for (index, bus) in self.buses.iter().enumerate() {
44            Master {
45                gain_db: bus.gain_db,
46                ..Default::default()
47            }
48            .validate()?;
49            if bus.name.is_empty()
50                || bus.name.len() > 128
51                || bus.effects.len() > 16
52                || self.buses[..index]
53                    .iter()
54                    .any(|other| other.name == bus.name)
55            {
56                return Err(invalid("Invalid or duplicate audio bus"));
57            }
58        }
59        Ok(())
60    }
61
62    /// Frame-aligned end of `clip` including bus effect tails. Add-time checks
63    /// and the final duration share this one definition.
64    pub(super) fn clip_end(&self, clip: &AudioClip, rate: FrameRate) -> crate::Result<Duration> {
65        let mut end = clip.end()?.since_origin().magnitude();
66        if let Some(bus) = self
67            .buses
68            .iter()
69            .find(|bus| Some(bus.name.as_str()) == clip.parameters().bus)
70        {
71            for effect in &bus.effects {
72                let tail = effect.tail();
73                if tail.as_seconds() > 30.0 {
74                    return Err(invalid("Bus effect tail requires 0..=30 seconds"));
75                }
76                end = Duration::from_ticks(
77                    end.ticks()
78                        .checked_add(tail.ticks())
79                        .ok_or_else(|| invalid("Bus tail exceeds frame clock"))?,
80                );
81            }
82        }
83        if end.as_seconds() > 86400.0 {
84            return Err(invalid("Audio end exceeds 24 hours"));
85        }
86        ceil_to_frame(end, rate)
87    }
88
89    /// Latest frame-aligned end over clips whose length is known; unresolved
90    /// files without a duration contribute nothing until they are resolved.
91    pub(super) fn end(&self, rate: FrameRate) -> crate::Result<Duration> {
92        let mut end = Duration::ZERO;
93        for clip in self.clips.iter() {
94            if clip.unresolved_file() && clip.parameters().duration.is_none() {
95                continue;
96            }
97            end = end.max(self.clip_end(clip, rate)?);
98        }
99        Ok(end)
100    }
101}
102
103/// Smallest frame boundary at or after `end`.
104pub(super) fn ceil_to_frame(end: Duration, rate: FrameRate) -> crate::Result<Duration> {
105    let frame = end.to_frame(rate);
106    let boundary = Duration::checked_from_frame(frame, rate)
107        .ok_or_else(|| invalid("Audio end exceeds frame clock"))?;
108    if boundary < end {
109        Duration::checked_from_frame(frame + 1, rate)
110            .ok_or_else(|| invalid("Audio end exceeds frame clock"))
111    } else {
112        Ok(boundary)
113    }
114}