Skip to main content

motion_core/
audio.rs

1//! Stateless audio sources and immutable timeline occurrence metadata.
2use crate::{Duration, ValidationError};
3use std::sync::Arc;
4
5mod clip;
6mod metadata;
7
8pub use clip::{AudioClip, AudioClipParameters};
9pub use metadata::{AudioChapter, AudioMetadata};
10
11fn invalid(field: &str, message: &str) -> ValidationError {
12    ValidationError::invalid("audio", field, message)
13}
14
15/// A deterministic offline effect. Mixers process from the occurrence origin once,
16/// retain the result, then serve exact random-access windows without IIR warmup errors.
17pub trait AudioEffect: Send + Sync + std::fmt::Debug {
18    /// Validates parameters at the native source rate, without processing PCM.
19    fn validate(&self, rate: u32) -> Result<(), ValidationError>;
20
21    /// Extra silent input needed to render a bounded decay tail.
22    fn tail(&self) -> Duration {
23        Duration::ZERO
24    }
25
26    /// Processes complete interleaved frames from a zero initial state.
27    fn process(&self, input: &mut [f32], channels: u16, rate: u32) -> crate::Result;
28}
29
30/// Stereo balance law. Mono panning always uses constant power.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub enum PanLaw {
33    /// Linear balance: attenuates only the opposite channel.
34    Linear,
35    /// Sine/cosine balance normalized to unity at the center; the default.
36    #[default]
37    ConstantPower,
38    /// Intermediate 4.5 dB pan law, normalized at the center.
39    Minus4_5dB,
40}
41
42/// Offline peak limiter parameters. The release is a finite linear gain recovery.
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct Limiter {
45    /// Maximum reconstructed peak in dBFS, in -24..=0.
46    pub ceiling_db: f64,
47    /// Future peak inspection interval, at most 20 milliseconds.
48    pub lookahead: Duration,
49    /// Gain recovery interval, in (0,2] seconds.
50    pub release: Duration,
51}
52
53/// EBU R128 target and reconstructed peak ceiling for static normalization.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct Loudness {
56    /// Target integrated loudness, in -70..=-5 LUFS.
57    pub target_lufs: f64,
58    /// Maximum reconstructed peak after gain, in -24..=0 dBFS.
59    pub true_peak_db: f64,
60}
61
62/// Optional master processing; the default leaves the mix unchanged.
63#[derive(Clone, Copy, Debug, Default, PartialEq)]
64pub struct Master {
65    /// Static gain in -96..=24 dB.
66    pub gain_db: f64,
67    /// Optional lookahead peak limiting.
68    pub limiter: Option<Limiter>,
69    /// Optional gated integrated loudness normalization.
70    pub normalize: Option<Loudness>,
71}
72
73impl Master {
74    /// Conservative online-video limiter preset; normalization remains opt-in.
75    pub fn safe() -> Self {
76        Self {
77            limiter: Some(Limiter {
78                ceiling_db: -1.0,
79                lookahead: Duration::from_ticks(crate::TICKS_PER_SECOND as u64 / 200),
80                release: Duration::from_ticks(crate::TICKS_PER_SECOND as u64 / 10),
81            }),
82            ..Self::default()
83        }
84    }
85
86    /// Checks all fields, including disabled processing branches.
87    pub fn validate(self) -> Result<(), ValidationError> {
88        if !self.gain_db.is_finite() || !(-96.0..=24.0).contains(&self.gain_db) {
89            return Err(invalid("master_gain", "Require finite -96..=24 dB"));
90        }
91        if self.limiter.is_some_and(|limit| {
92            !limit.ceiling_db.is_finite()
93                || !(-24.0..=0.0).contains(&limit.ceiling_db)
94                || limit.lookahead.as_seconds() > 0.02
95                || limit.release <= Duration::ZERO
96                || limit.release.as_seconds() > 2.0
97        }) {
98            return Err(invalid("limiter", "Invalid ceiling, lookahead or release"));
99        }
100        if self.normalize.is_some_and(|normal| {
101            !normal.target_lufs.is_finite()
102                || !(-70.0..=-5.0).contains(&normal.target_lufs)
103                || !normal.true_peak_db.is_finite()
104                || !(-24.0..=0.0).contains(&normal.true_peak_db)
105        }) {
106            return Err(invalid(
107                "normalize",
108                "Invalid LUFS target or true peak ceiling",
109            ));
110        }
111        Ok(())
112    }
113}
114
115/// Sidechain ducking parameters. The trigger's peak-hold envelope is converted to a gain
116/// reduction in dB, smoothed by attack/release and applied to the ducked signal.
117#[derive(Clone, Copy, Debug, PartialEq)]
118pub struct Duck {
119    /// Trigger level above which ducking starts, in -96..=0 dBFS.
120    pub threshold_db: f64,
121    /// Maximum gain reduction, in 0..=96 dB.
122    pub depth_db: f64,
123    /// Compression ratio above the threshold, in 1..=100; reduction is capped by `depth_db`.
124    pub ratio: f64,
125    /// Duration to reach a deeper reduction, in 0..=5 seconds.
126    pub attack: Duration,
127    /// Duration to recover after the trigger falls, in 0..=10 seconds.
128    pub release: Duration,
129    /// Interval by which ducking anticipates the trigger, in 0..=500 milliseconds.
130    pub lookahead: Duration,
131}
132
133impl Default for Duck {
134    fn default() -> Self {
135        Self {
136            threshold_db: -45.0,
137            depth_db: 12.0,
138            ratio: 100.0,
139            attack: Duration::from_ticks(crate::TICKS_PER_SECOND as u64 / 1000 * 80),
140            release: Duration::from_ticks(crate::TICKS_PER_SECOND as u64 / 1000 * 400),
141            lookahead: Duration::from_ticks(crate::TICKS_PER_SECOND as u64 / 100),
142        }
143    }
144}
145
146impl Duck {
147    /// Creates ducking with the given threshold and depth and default timing.
148    pub fn new(threshold_db: f64, depth_db: f64) -> Self {
149        Self {
150            threshold_db,
151            depth_db,
152            ..Self::default()
153        }
154    }
155
156    /// Sets the compression ratio above the threshold.
157    pub fn ratio(mut self, ratio: f64) -> Self {
158        self.ratio = ratio;
159        self
160    }
161
162    /// Sets attack and release times.
163    pub fn timing(mut self, attack: Duration, release: Duration) -> Self {
164        self.attack = attack;
165        self.release = release;
166        self
167    }
168
169    /// Sets the anticipation interval.
170    pub fn lookahead(mut self, lookahead: Duration) -> Self {
171        self.lookahead = lookahead;
172        self
173    }
174
175    /// Checks finite bounded parameters.
176    pub fn validate(self) -> Result<(), ValidationError> {
177        let seconds =
178            |time: Duration, max: f64| time.as_seconds().is_finite() && time.as_seconds() <= max;
179        if !self.threshold_db.is_finite()
180            || !(-96.0..=0.0).contains(&self.threshold_db)
181            || !self.depth_db.is_finite()
182            || !(0.0..=96.0).contains(&self.depth_db)
183            || !self.ratio.is_finite()
184            || !(1.0..=100.0).contains(&self.ratio)
185            || !seconds(self.attack, 5.0)
186            || !seconds(self.release, 10.0)
187            || !seconds(self.lookahead, 0.5)
188        {
189            return Err(invalid(
190                "duck",
191                "Require threshold -96..=0 dB, depth 0..=96 dB, ratio 1..=100, attack <=5 s, release <=10 s and lookahead <=0.5 s",
192            ));
193        }
194        Ok(())
195    }
196}
197
198/// Signal that drives a [`Duck`].
199#[derive(Clone, Debug)]
200pub enum DuckTrigger {
201    /// Another occurrence, rendered standalone at its own gain and pan. Its own ducking is ignored.
202    Clip(Arc<AudioClip>),
203    /// An earlier-declared bus, measured after its effects, gain and ducking.
204    Bus(String),
205}
206
207/// A sidechain ducking assignment.
208#[derive(Clone, Debug)]
209pub struct Ducking {
210    /// Signal that lowers the target.
211    pub trigger: DuckTrigger,
212    /// Detector and gain-reduction parameters.
213    pub duck: Duck,
214}
215
216/// Named submix processed before the master. At most sixteen buses are admitted.
217#[derive(Clone, Debug)]
218pub struct AudioBus {
219    /// Stable name matched by AudioClip::bus.
220    pub name: String,
221    /// Static gain, in -96..=24 dB.
222    pub gain_db: f64,
223    /// Deterministic offline effects, in declaration order.
224    pub effects: Vec<Arc<dyn AudioEffect>>,
225    /// Sidechain ducking applied after effects and gain; bus triggers must be declared earlier.
226    pub ducking: Vec<Ducking>,
227}
228
229impl AudioBus {
230    /// Creates an identity submix with the supplied name.
231    pub fn new(name: impl Into<String>) -> Self {
232        Self {
233            name: name.into(),
234            gain_db: 0.0,
235            effects: Vec::new(),
236            ducking: Vec::new(),
237        }
238    }
239
240    /// Ducks the whole submix under a clip or earlier bus.
241    pub fn ducked_by(mut self, trigger: DuckTrigger, duck: Duck) -> Self {
242        self.ducking.push(Ducking { trigger, duck });
243        self
244    }
245}
246
247/// A pure, concurrent random-access interleaved PCM source.
248/// Repeated reads must not depend on prior requests. Implementations return frames,
249/// never samples; EOF/short reads leave the rest of the caller's buffer silent.
250pub trait AudioSource: Send + Sync {
251    /// Deferred child mix, resolved by the encoding boundary before PCM queries.
252    fn composition(&self) -> Option<&crate::Timeline> {
253        None
254    }
255
256    /// Optional local file identity, used by execution boundaries for lazy decoding.
257    fn file_path(&self) -> Option<&std::path::Path> {
258        None
259    }
260
261    /// Checked reads let file-backed sources propagate decoding failures.
262    fn try_read(&self, start_frame: u64, output: &mut [f32]) -> crate::Result<usize> {
263        Ok(self.read(start_frame, output))
264    }
265
266    /// Native PCM sampling frequency, in frames per second.
267    fn sample_rate(&self) -> u32;
268
269    /// Interleaved channel count; mixing supports mono and stereo.
270    fn channels(&self) -> u16;
271
272    /// Reads complete frames at an absolute source offset. The return value must
273    /// not exceed output.len()/channels; all returned samples must be finite.
274    fn read(&self, start_frame: u64, output: &mut [f32]) -> usize;
275
276    /// Finite source duration, or None for a procedurally unbounded source.
277    fn duration(&self) -> Option<Duration>;
278}
279
280impl<Source: AudioSource + ?Sized> AudioSource for Arc<Source> {
281    fn composition(&self) -> Option<&crate::Timeline> {
282        self.as_ref().composition()
283    }
284
285    fn file_path(&self) -> Option<&std::path::Path> {
286        self.as_ref().file_path()
287    }
288
289    fn try_read(&self, start: u64, output: &mut [f32]) -> crate::Result<usize> {
290        self.as_ref().try_read(start, output)
291    }
292
293    fn sample_rate(&self) -> u32 {
294        self.as_ref().sample_rate()
295    }
296
297    fn channels(&self) -> u16 {
298        self.as_ref().channels()
299    }
300
301    fn read(&self, start: u64, output: &mut [f32]) -> usize {
302        self.as_ref().read(start, output)
303    }
304
305    fn duration(&self) -> Option<Duration> {
306        self.as_ref().duration()
307    }
308}
309
310/// Converts ticks to the nearest audio frame, with half ties upward.
311/// A zero sampling rate produces zero frames.
312pub fn frames(time: Duration, rate: u32) -> u64 {
313    ((u128::from(time.ticks()) * u128::from(rate) + crate::TICKS_PER_SECOND as u128 / 2)
314        / crate::TICKS_PER_SECOND as u128) as u64
315}
316
317/// Converts a duration to the smallest covering number of audio frames.
318/// Integer arithmetic avoids floating-point endpoint drift.
319pub fn ceil_frames(time: Duration, rate: u32) -> u64 {
320    (u128::from(time.ticks()) * u128::from(rate)).div_ceil(crate::TICKS_PER_SECOND as u128) as u64
321}