Skip to main content

motion_core/
audio.rs

1//! Stateless audio sources and immutable timeline occurrence metadata.
2use crate::{Duration, Time, 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        Time::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: Time::from_ticks(crate::TICKS_PER_SECOND / 200),
80                release: Time::from_ticks(crate::TICKS_PER_SECOND / 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 < Time::ZERO
95                || limit.lookahead.as_seconds() > 0.02
96                || limit.release <= Time::ZERO
97                || limit.release.as_seconds() > 2.0
98        }) {
99            return Err(invalid("limiter", "Invalid ceiling, lookahead or release"));
100        }
101        if self.normalize.is_some_and(|normal| {
102            !normal.target_lufs.is_finite()
103                || !(-70.0..=-5.0).contains(&normal.target_lufs)
104                || !normal.true_peak_db.is_finite()
105                || !(-24.0..=0.0).contains(&normal.true_peak_db)
106        }) {
107            return Err(invalid(
108                "normalize",
109                "Invalid LUFS target or true peak ceiling",
110            ));
111        }
112        Ok(())
113    }
114}
115
116/// Sidechain ducking parameters. The trigger's peak-hold envelope is converted to a gain
117/// reduction in dB, smoothed by attack/release and applied to the ducked signal.
118#[derive(Clone, Copy, Debug, PartialEq)]
119pub struct Duck {
120    /// Trigger level above which ducking starts, in -96..=0 dBFS.
121    pub threshold_db: f64,
122    /// Maximum gain reduction, in 0..=96 dB.
123    pub depth_db: f64,
124    /// Compression ratio above the threshold, in 1..=100; reduction is capped by `depth_db`.
125    pub ratio: f64,
126    /// Time to reach a deeper reduction, in 0..=5 seconds.
127    pub attack: Duration,
128    /// Time to recover after the trigger falls, in 0..=10 seconds.
129    pub release: Duration,
130    /// Interval by which ducking anticipates the trigger, in 0..=500 milliseconds.
131    pub lookahead: Duration,
132}
133
134impl Default for Duck {
135    fn default() -> Self {
136        Self {
137            threshold_db: -45.0,
138            depth_db: 12.0,
139            ratio: 100.0,
140            attack: Time::from_ticks(crate::TICKS_PER_SECOND / 1000 * 80),
141            release: Time::from_ticks(crate::TICKS_PER_SECOND / 1000 * 400),
142            lookahead: Time::from_ticks(crate::TICKS_PER_SECOND / 100),
143        }
144    }
145}
146
147impl Duck {
148    /// Creates ducking with the given threshold and depth and default timing.
149    pub fn new(threshold_db: f64, depth_db: f64) -> Self {
150        Self {
151            threshold_db,
152            depth_db,
153            ..Self::default()
154        }
155    }
156
157    /// Sets the compression ratio above the threshold.
158    pub fn ratio(mut self, ratio: f64) -> Self {
159        self.ratio = ratio;
160        self
161    }
162
163    /// Sets attack and release times.
164    pub fn timing(mut self, attack: Duration, release: Duration) -> Self {
165        self.attack = attack;
166        self.release = release;
167        self
168    }
169
170    /// Sets the anticipation interval.
171    pub fn lookahead(mut self, lookahead: Duration) -> Self {
172        self.lookahead = lookahead;
173        self
174    }
175
176    /// Checks finite bounded parameters.
177    pub fn validate(self) -> Result<(), ValidationError> {
178        let seconds = |time: Duration, max: f64| {
179            time >= Time::ZERO && time.as_seconds().is_finite() && time.as_seconds() <= max
180        };
181        if !self.threshold_db.is_finite()
182            || !(-96.0..=0.0).contains(&self.threshold_db)
183            || !self.depth_db.is_finite()
184            || !(0.0..=96.0).contains(&self.depth_db)
185            || !self.ratio.is_finite()
186            || !(1.0..=100.0).contains(&self.ratio)
187            || !seconds(self.attack, 5.0)
188            || !seconds(self.release, 10.0)
189            || !seconds(self.lookahead, 0.5)
190        {
191            return Err(invalid(
192                "duck",
193                "Require threshold -96..=0 dB, depth 0..=96 dB, ratio 1..=100, attack <=5 s, release <=10 s and lookahead <=0.5 s",
194            ));
195        }
196        Ok(())
197    }
198}
199
200/// Signal that drives a [`Duck`].
201#[derive(Clone, Debug)]
202pub enum DuckTrigger {
203    /// Another occurrence, rendered standalone at its own gain and pan. Its own ducking is ignored.
204    Clip(Arc<AudioClip>),
205    /// An earlier-declared bus, measured after its effects, gain and ducking.
206    Bus(String),
207}
208
209/// A sidechain ducking assignment.
210#[derive(Clone, Debug)]
211pub struct Ducking {
212    /// Signal that lowers the target.
213    pub trigger: DuckTrigger,
214    /// Detector and gain-reduction parameters.
215    pub duck: Duck,
216}
217
218/// Named submix processed before the master. At most sixteen buses are admitted.
219#[derive(Clone, Debug)]
220pub struct AudioBus {
221    /// Stable name matched by AudioClip::bus.
222    pub name: String,
223    /// Static gain, in -96..=24 dB.
224    pub gain_db: f64,
225    /// Deterministic offline effects, in declaration order.
226    pub effects: Vec<Arc<dyn AudioEffect>>,
227    /// Sidechain ducking applied after effects and gain; bus triggers must be declared earlier.
228    pub ducking: Vec<Ducking>,
229}
230
231impl AudioBus {
232    /// Creates an identity submix with the supplied name.
233    pub fn new(name: impl Into<String>) -> Self {
234        Self {
235            name: name.into(),
236            gain_db: 0.0,
237            effects: Vec::new(),
238            ducking: Vec::new(),
239        }
240    }
241
242    /// Ducks the whole submix under a clip or earlier bus.
243    pub fn ducked_by(mut self, trigger: DuckTrigger, duck: Duck) -> Self {
244        self.ducking.push(Ducking { trigger, duck });
245        self
246    }
247}
248
249/// A pure, concurrent random-access interleaved PCM source.
250/// Repeated reads must not depend on prior requests. Implementations return frames,
251/// never samples; EOF/short reads leave the rest of the caller's buffer silent.
252pub trait AudioSource: Send + Sync {
253    /// Deferred child mix, resolved by the encoding boundary before PCM queries.
254    fn composition(&self) -> Option<&crate::Timeline> {
255        None
256    }
257
258    /// Optional local file identity, used by execution boundaries for lazy decoding.
259    fn file_path(&self) -> Option<&std::path::Path> {
260        None
261    }
262
263    /// Checked reads let file-backed sources propagate decoding failures.
264    fn try_read(&self, start_frame: u64, output: &mut [f32]) -> crate::Result<usize> {
265        Ok(self.read(start_frame, output))
266    }
267
268    /// Native PCM sampling frequency, in frames per second.
269    fn sample_rate(&self) -> u32;
270
271    /// Interleaved channel count; mixing supports mono and stereo.
272    fn channels(&self) -> u16;
273
274    /// Reads complete frames at an absolute source offset. The return value must
275    /// not exceed output.len()/channels; all returned samples must be finite.
276    fn read(&self, start_frame: u64, output: &mut [f32]) -> usize;
277
278    /// Finite source duration, or None for a procedurally unbounded source.
279    fn duration(&self) -> Option<Duration>;
280}
281
282impl<Source: AudioSource + ?Sized> AudioSource for Arc<Source> {
283    fn composition(&self) -> Option<&crate::Timeline> {
284        self.as_ref().composition()
285    }
286
287    fn file_path(&self) -> Option<&std::path::Path> {
288        self.as_ref().file_path()
289    }
290
291    fn try_read(&self, start: u64, output: &mut [f32]) -> crate::Result<usize> {
292        self.as_ref().try_read(start, output)
293    }
294
295    fn sample_rate(&self) -> u32 {
296        self.as_ref().sample_rate()
297    }
298
299    fn channels(&self) -> u16 {
300        self.as_ref().channels()
301    }
302
303    fn read(&self, start: u64, output: &mut [f32]) -> usize {
304        self.as_ref().read(start, output)
305    }
306
307    fn duration(&self) -> Option<Duration> {
308        self.as_ref().duration()
309    }
310}
311
312/// Converts nonnegative ticks to the nearest audio frame, with half ties upward.
313/// Negative times fail; a zero sampling rate produces zero frames.
314pub fn frames(time: Time, rate: u32) -> Result<u64, ValidationError> {
315    if time < Time::ZERO {
316        return Err(invalid("time", "Audio time must be nonnegative"));
317    }
318    Ok(
319        ((u128::from(time.ticks() as u64) * u128::from(rate) + crate::TICKS_PER_SECOND as u128 / 2)
320            / crate::TICKS_PER_SECOND as u128) as u64,
321    )
322}
323
324/// Converts a nonnegative duration to the smallest covering number of audio frames.
325/// Negative durations fail; integer arithmetic avoids floating-point endpoint drift.
326pub fn ceil_frames(time: Time, rate: u32) -> Result<u64, ValidationError> {
327    if time < Time::ZERO {
328        return Err(invalid("duration", "Audio duration must be nonnegative"));
329    }
330    Ok((u128::from(time.ticks() as u64) * u128::from(rate))
331        .div_ceil(crate::TICKS_PER_SECOND as u128) as u64)
332}