Skip to main content

motion_core/audio/
clip.rs

1//! Tick-authored audio clip occurrences, their placement parameters and internal sources.
2
3use super::{AudioEffect, AudioSource, Duck, DuckTrigger, Ducking, PanLaw, invalid};
4use crate::{Duration, Time, ValidationError};
5use std::sync::Arc;
6
7/// tick-authored audio occurrence, separate from the retained root AudioClip.
8#[derive(Clone)]
9pub struct AudioClip {
10    source: Arc<dyn AudioSource>,
11    start: Time,
12    offset: Time,
13    duration: Option<Duration>,
14    volume: f64,
15    fade_in: Duration,
16    fade_out: Duration,
17    pan: f64,
18    envelope: Option<Arc<dyn crate::Animation<f64>>>,
19    limit: Option<Duration>,
20    effects: Arc<Vec<Arc<dyn AudioEffect>>>,
21    pan_animation: Option<Arc<dyn crate::Animation<f64>>>,
22    pan_law: PanLaw,
23    stereo_width: f64,
24    bus: Option<String>,
25    ducking: Arc<Vec<Ducking>>,
26}
27
28impl<Source: AudioSource + ?Sized + 'static> From<Arc<Source>> for AudioClip {
29    fn from(source: Arc<Source>) -> Self {
30        Self::source(source)
31    }
32}
33
34/// Immutable placement and envelope inputs consumed by audio mixers.
35pub struct AudioClipParameters<'a> {
36    /// Stateless source captured by this occurrence.
37    pub source: &'a dyn AudioSource,
38    /// Destination start on the local timeline.
39    pub start: Time,
40    /// First source sample time.
41    pub offset: Time,
42    /// Authored duration override, absent when natural file duration is unresolved.
43    pub duration: Option<Duration>,
44    /// Optional containing-scene crop.
45    pub limit: Option<Duration>,
46    /// Linear amplitude multiplier.
47    pub volume: f64,
48    /// Fade-in duration.
49    pub fade_in: Duration,
50    /// Fade-out duration.
51    pub fade_out: Duration,
52    /// Optional pure local-time gain animation.
53    pub envelope: Option<&'a dyn crate::Animation<f64>>,
54    /// Mono/stereo pan setting.
55    pub pan: f64,
56    /// Optional local-time pan animation, replacing the constant pan.
57    pub pan_animation: Option<&'a dyn crate::Animation<f64>>,
58    /// Stereo balance law.
59    pub pan_law: PanLaw,
60    /// Mid/side width in 0..=2; one preserves the stereo source.
61    pub stereo_width: f64,
62    /// Effects applied at native rate before gain and pan.
63    pub effects: &'a [Arc<dyn AudioEffect>],
64    /// Optional named submix.
65    pub bus: Option<&'a str>,
66    /// Sidechain ducking by other clips, applied after gain and pan.
67    pub ducking: &'a [Ducking],
68}
69
70impl std::fmt::Debug for AudioClip {
71    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        formatter
73            .debug_struct("AudioClip")
74            .field("start", &self.start)
75            .field("offset", &self.offset)
76            .field("duration", &self.duration)
77            .field("volume", &self.volume)
78            .field("pan", &self.pan)
79            .finish_non_exhaustive()
80    }
81}
82
83impl AudioClip {
84    /// Retains a nested mix, including its own buses and master, without decoding.
85    pub fn composition(timeline: crate::Timeline) -> Self {
86        Self::source(CompositionSource(timeline))
87    }
88
89    /// Retains a local file path without opening, probing or decoding it.
90    pub fn file(path: impl Into<std::path::PathBuf>) -> Self {
91        Self::source(FileReference(path.into()))
92    }
93
94    /// Replaces a deferred source at an execution boundary, preserving all authored settings.
95    pub fn with_resolved_source(&self, source: Arc<dyn AudioSource>) -> Self {
96        let mut clip = self.clone();
97        clip.source = source;
98        clip
99    }
100
101    /// Whether metadata is still required to determine a natural file duration.
102    pub fn unresolved_file(&self) -> bool {
103        self.source.file_path().is_some() && self.source.duration().is_none()
104    }
105
106    /// Borrows mixer parameters without reading, decoding or copying PCM.
107    pub fn parameters(&self) -> AudioClipParameters<'_> {
108        AudioClipParameters {
109            source: self.source.as_ref(),
110            start: self.start,
111            offset: self.offset,
112            duration: self.duration,
113            limit: self.limit,
114            volume: self.volume,
115            fade_in: self.fade_in,
116            fade_out: self.fade_out,
117            envelope: self.envelope.as_deref(),
118            pan: self.pan,
119            pan_animation: self.pan_animation.as_deref(),
120            pan_law: self.pan_law,
121            stereo_width: self.stereo_width,
122            effects: &self.effects,
123            bus: self.bus.as_deref(),
124            ducking: &self.ducking,
125        }
126    }
127
128    /// Captures a stateless source. Infinite sources require an explicit duration.
129    pub fn source(source: impl AudioSource + 'static) -> Self {
130        Self {
131            source: Arc::new(source),
132            start: Time::ZERO,
133            offset: Time::ZERO,
134            duration: None,
135            volume: 1.0,
136            fade_in: Time::ZERO,
137            fade_out: Time::ZERO,
138            pan: 0.0,
139            envelope: None,
140            limit: None,
141            effects: Arc::new(Vec::new()),
142            pan_animation: None,
143            pan_law: PanLaw::ConstantPower,
144            stereo_width: 1.0,
145            bus: None,
146            ducking: Arc::new(Vec::new()),
147        }
148    }
149
150    /// Sets destination start in nonnegative ticks.
151    pub fn start(mut self, start: Time) -> Self {
152        self.start = start;
153        self
154    }
155
156    /// Sets source offset, rounded once to the nearest source-rate frame.
157    pub fn offset(mut self, offset: Time) -> Self {
158        self.offset = offset;
159        self
160    }
161
162    /// Sets a finite positive occurrence duration; EOF is padded with silence.
163    pub fn duration(mut self, duration: Duration) -> Self {
164        self.duration = Some(duration);
165        self
166    }
167
168    /// Sets finite amplitude in 0..=16; mixing never normalizes peaks automatically.
169    pub fn volume(mut self, volume: f64) -> Self {
170        self.volume = volume;
171        self
172    }
173
174    /// Multiplies volume by a typed animation sampled at clip-local output time.
175    pub fn volume_envelope(mut self, envelope: impl crate::Animation<f64> + 'static) -> Self {
176        self.envelope = Some(Arc::new(envelope));
177        self
178    }
179
180    /// Linear amplitude fade anchored to the clip start; overlapping fades multiply.
181    pub fn fade_in(mut self, duration: Duration) -> Self {
182        self.fade_in = duration;
183        self
184    }
185
186    /// Linear amplitude fade ending at the occurrence's exclusive end.
187    pub fn fade_out(mut self, duration: Duration) -> Self {
188        self.fade_out = duration;
189        self
190    }
191
192    /// Constant-power mono pan; stereo uses balance. Range -1 left through +1 right.
193    pub fn pan(mut self, pan: f64) -> Self {
194        self.pan = pan;
195        self
196    }
197
198    /// Appends an offline effect, rendered once from the clip origin.
199    pub fn effect(mut self, effect: impl AudioEffect + 'static) -> Self {
200        Arc::make_mut(&mut self.effects).push(Arc::new(effect));
201        self
202    }
203
204    /// Lowers this clip while `trigger` is audible. The trigger is still mixed on its own
205    /// when it is also added to the timeline; at most four triggers per clip. Ducking is
206    /// rendered offline from the trigger envelope, so it is deterministic and random-access safe.
207    pub fn ducked_by(mut self, trigger: &AudioClip, duck: Duck) -> Self {
208        let mut trigger = trigger.clone();
209        trigger.ducking = Arc::new(Vec::new());
210        Arc::make_mut(&mut self.ducking).push(Ducking {
211            trigger: DuckTrigger::Clip(Arc::new(trigger)),
212            duck,
213        });
214        self
215    }
216
217    /// Routes the occurrence to a named timeline bus.
218    pub fn bus(mut self, name: impl Into<String>) -> Self {
219        self.bus = Some(name.into());
220        self
221    }
222
223    /// Replaces stereo balance law; mono panning is unaffected.
224    pub fn pan_law(mut self, law: PanLaw) -> Self {
225        self.pan_law = law;
226        self
227    }
228
229    /// Sets a pure local-time pan animation, with values in -1..=1.
230    pub fn pan_animation(mut self, animation: impl crate::Animation<f64> + 'static) -> Self {
231        self.pan_animation = Some(Arc::new(animation));
232        self
233    }
234
235    /// Sets mid/side width in 0..=2, without changing the mono component.
236    pub fn stereo_width(mut self, width: f64) -> Self {
237        self.stereo_width = width;
238        self
239    }
240
241    /// Couples screen position to pan and inverse-distance gain, multiplying the existing envelope.
242    /// Width is the listener-to-edge distance; this is not HRTF or three-dimensional spatial audio.
243    pub fn spatial(
244        mut self,
245        position: impl crate::Animation<crate::Point> + 'static,
246        listener: crate::Point,
247        width: f64,
248        rolloff: f64,
249    ) -> Result<Self, ValidationError> {
250        if !listener.x.is_finite()
251            || !listener.y.is_finite()
252            || !width.is_finite()
253            || width <= 0.0
254            || !rolloff.is_finite()
255            || !(0.0..=1000.0).contains(&rolloff)
256        {
257            return Err(invalid(
258                "spatial",
259                "Invalid listener, width or distance rolloff",
260            ));
261        }
262        let position: Arc<dyn crate::Animation<crate::Point>> = Arc::new(position);
263        self.pan_animation = Some(Arc::new(SpatialPan {
264            position: position.clone(),
265            listener,
266            width,
267        }));
268        self.envelope = Some(Arc::new(SpatialGain {
269            position,
270            listener,
271            width,
272            rolloff,
273            previous: self.envelope.take(),
274        }));
275        Ok(self)
276    }
277
278    /// Selects a positive source-time interval; natural duration is not required.
279    pub fn trim(self, start: Time, end: Time) -> Self {
280        self.offset(start).duration(end - start)
281    }
282
283    /// Validated natural/overridden duration, before destination placement.
284    pub fn resolved_duration(&self) -> Result<Duration, ValidationError> {
285        let mut duration = self
286            .duration
287            .or_else(|| {
288                self.source.duration().and_then(|duration| {
289                    duration
290                        .ticks()
291                        .checked_sub(self.offset.ticks())
292                        .map(Time::from_ticks)
293                })
294            })
295            .ok_or_else(|| invalid("duration", "Unbounded audio requires an explicit duration"))?;
296        if duration <= Time::ZERO {
297            return Err(invalid(
298                "duration",
299                "Clip source duration must be positive before effect tails",
300            ));
301        }
302        if self.effects.len() > 16 {
303            return Err(invalid("effects", "At most sixteen effects per clip"));
304        }
305        if self.ducking.len() > 4 {
306            return Err(invalid("ducking", "At most four ducking triggers per clip"));
307        }
308        for ducking in self.ducking.iter() {
309            ducking.duck.validate()?;
310            match &ducking.trigger {
311                DuckTrigger::Clip(trigger) if !trigger.unresolved_file() => {
312                    trigger.resolved_duration()?;
313                }
314                DuckTrigger::Clip(_) => {}
315                DuckTrigger::Bus(_) => {
316                    return Err(invalid("ducking", "Clip ducking requires a clip trigger"));
317                }
318            }
319        }
320        for effect in self.effects.iter() {
321            effect.validate(self.source.sample_rate())?;
322            let tail = effect.tail();
323            if tail < Time::ZERO || tail.as_seconds() > 30.0 {
324                return Err(invalid("tail", "Effect tail must be in 0..=30 seconds"));
325            }
326            duration = Time::from_ticks(
327                duration
328                    .ticks()
329                    .checked_add(tail.ticks())
330                    .ok_or_else(|| invalid("tail", "Effect duration overflow"))?,
331            );
332        }
333        if self.start < Time::ZERO
334            || self.offset < Time::ZERO
335            || duration <= Time::ZERO
336            || duration.as_seconds() > 86400.0
337            || self
338                .start
339                .ticks()
340                .checked_add(duration.ticks())
341                .is_none_or(|end| end > 86400 * crate::TICKS_PER_SECOND)
342            || !self.volume.is_finite()
343            || !(0.0..=16.0).contains(&self.volume)
344            || !self.pan.is_finite()
345            || !(-1.0..=1.0).contains(&self.pan)
346            || !self.stereo_width.is_finite()
347            || !(0.0..=2.0).contains(&self.stereo_width)
348            || self
349                .bus
350                .as_ref()
351                .is_some_and(|name| name.is_empty() || name.len() > 128)
352            || self.fade_in < Time::ZERO
353            || self.fade_out < Time::ZERO
354            || self.fade_in > duration
355            || self.fade_out > duration
356            || !(1..=192_000).contains(&self.source.sample_rate())
357            || !(1..=2).contains(&self.source.channels())
358        {
359            return Err(invalid(
360                "clip",
361                "Invalid audio timing, format, volume, pan or fade",
362            ));
363        }
364        Ok(duration)
365    }
366
367    /// Exclusive destination end, checked without reading samples.
368    pub fn end(&self) -> Result<Time, ValidationError> {
369        let duration = self.resolved_duration()?;
370        Ok(self.start + self.limit.map_or(duration, |limit| limit.min(duration)))
371    }
372
373    /// Places a nested audio occurrence in a parent timeline, cropping its audible window.
374    /// Source offsets and fade positions remain anchored to the original clip.
375    pub fn relocated(
376        &self,
377        offset: Time,
378        window: Duration,
379    ) -> Result<Option<Self>, ValidationError> {
380        if !self.unresolved_file() {
381            self.resolved_duration()?;
382        }
383        if self.start >= window {
384            return Ok(None);
385        }
386        let mut clip = self.clone();
387        let mut ducking = Vec::new();
388        for item in self.ducking.iter() {
389            if let DuckTrigger::Clip(trigger) = &item.trigger
390                && let Some(trigger) = trigger.relocated(offset, window)?
391            {
392                ducking.push(Ducking {
393                    trigger: DuckTrigger::Clip(Arc::new(trigger)),
394                    duck: item.duck,
395                });
396            }
397        }
398        clip.ducking = Arc::new(ducking);
399        let remaining = window - clip.start;
400        clip.limit = Some(clip.limit.map_or(remaining, |limit| limit.min(remaining)));
401        clip.start = Time::from_ticks(
402            offset
403                .ticks()
404                .checked_add(clip.start.ticks())
405                .ok_or_else(|| invalid("nested_audio", "Nested audio placement overflow"))?,
406        );
407        if !clip.unresolved_file() {
408            clip.resolved_duration()?;
409        }
410        Ok(Some(clip))
411    }
412}
413
414struct CompositionSource(crate::Timeline);
415
416struct SpatialPan {
417    position: Arc<dyn crate::Animation<crate::Point>>,
418    listener: crate::Point,
419    width: f64,
420}
421
422impl crate::Animation<f64> for SpatialPan {
423    fn sample(&self, time: Time) -> f64 {
424        let point = self.position.sample(time);
425        if !point.x.is_finite() || !point.y.is_finite() {
426            return f64::NAN;
427        }
428        ((point.x - self.listener.x) / self.width).clamp(-1.0, 1.0)
429    }
430
431    fn duration(&self) -> Option<Duration> {
432        self.position.duration()
433    }
434}
435
436struct SpatialGain {
437    position: Arc<dyn crate::Animation<crate::Point>>,
438    listener: crate::Point,
439    width: f64,
440    rolloff: f64,
441    previous: Option<Arc<dyn crate::Animation<f64>>>,
442}
443
444impl crate::Animation<f64> for SpatialGain {
445    fn sample(&self, time: Time) -> f64 {
446        let point = self.position.sample(time);
447        if !point.x.is_finite() || !point.y.is_finite() {
448            return f64::NAN;
449        }
450        let distance =
451            libm::hypot(point.x - self.listener.x, point.y - self.listener.y) / self.width;
452        self.previous
453            .as_ref()
454            .map_or(1.0, |previous| previous.sample(time))
455            / (1.0 + self.rolloff * distance)
456    }
457
458    fn duration(&self) -> Option<Duration> {
459        self.position.duration()
460    }
461}
462
463impl AudioSource for CompositionSource {
464    fn composition(&self) -> Option<&crate::Timeline> {
465        Some(&self.0)
466    }
467
468    fn sample_rate(&self) -> u32 {
469        48000
470    }
471
472    fn channels(&self) -> u16 {
473        2
474    }
475
476    fn duration(&self) -> Option<Duration> {
477        Some(self.0.total_duration())
478    }
479
480    fn read(&self, _: u64, output: &mut [f32]) -> usize {
481        output.fill(0.0);
482        0
483    }
484
485    fn try_read(&self, _: u64, _: &mut [f32]) -> crate::Result<usize> {
486        Err(std::io::Error::new(
487            std::io::ErrorKind::Unsupported,
488            "Deferred composition requires audio preparation",
489        )
490        .into())
491    }
492}
493
494struct FileReference(std::path::PathBuf);
495
496impl AudioSource for FileReference {
497    fn file_path(&self) -> Option<&std::path::Path> {
498        Some(&self.0)
499    }
500
501    fn sample_rate(&self) -> u32 {
502        48_000
503    }
504
505    fn channels(&self) -> u16 {
506        2
507    }
508
509    fn read(&self, _: u64, output: &mut [f32]) -> usize {
510        output.fill(0.0);
511        0
512    }
513
514    fn try_read(&self, _: u64, _: &mut [f32]) -> crate::Result<usize> {
515        Err(crate::Error::asset(
516            "audio",
517            self.0.display().to_string(),
518            std::io::Error::new(
519                std::io::ErrorKind::InvalidInput,
520                "File audio must resolve at an execution boundary",
521            ),
522        ))
523    }
524
525    fn duration(&self) -> Option<Duration> {
526        None
527    }
528}