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: Duration,
13    duration: Option<Duration>,
14    volume: f64,
15    fade_in: Duration,
16    fade_out: Duration,
17    pan: f64,
18    envelope: Option<Arc<dyn crate::Signal<f64>>>,
19    limit: Option<Duration>,
20    effects: Arc<Vec<Arc<dyn AudioEffect>>>,
21    pan_animation: Option<Arc<dyn crate::Signal<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: Duration,
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::Signal<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::Signal<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: Duration::ZERO,
134            duration: None,
135            volume: 1.0,
136            fade_in: Duration::ZERO,
137            fade_out: Duration::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: Duration) -> 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::Signal<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::Signal<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::Signal<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::Signal<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    /// An `end` not after `start` yields an empty duration, rejected when resolved.
280    pub fn trim(self, start: Duration, end: Duration) -> Self {
281        self.offset(start).duration(end.saturating_sub(start))
282    }
283
284    /// Validated natural/overridden duration, before destination placement.
285    pub fn resolved_duration(&self) -> Result<Duration, ValidationError> {
286        let mut duration = self
287            .duration
288            .or_else(|| {
289                self.source
290                    .duration()
291                    .map(|duration| duration.saturating_sub(self.offset))
292            })
293            .ok_or_else(|| invalid("duration", "Unbounded audio requires an explicit duration"))?;
294        if duration <= Duration::ZERO {
295            return Err(invalid(
296                "duration",
297                "Clip source duration must be positive before effect tails",
298            ));
299        }
300        if self.effects.len() > 16 {
301            return Err(invalid("effects", "At most sixteen effects per clip"));
302        }
303        if self.ducking.len() > 4 {
304            return Err(invalid("ducking", "At most four ducking triggers per clip"));
305        }
306        for ducking in self.ducking.iter() {
307            ducking.duck.validate()?;
308            match &ducking.trigger {
309                DuckTrigger::Clip(trigger) if !trigger.unresolved_file() => {
310                    trigger.resolved_duration()?;
311                }
312                DuckTrigger::Clip(_) => {}
313                DuckTrigger::Bus(_) => {
314                    return Err(invalid("ducking", "Clip ducking requires a clip trigger"));
315                }
316            }
317        }
318        for effect in self.effects.iter() {
319            effect.validate(self.source.sample_rate())?;
320            let tail = effect.tail();
321            if tail.as_seconds() > 30.0 {
322                return Err(invalid("tail", "Effect tail must be in 0..=30 seconds"));
323            }
324            duration = Duration::from_ticks(
325                duration
326                    .ticks()
327                    .checked_add(tail.ticks())
328                    .ok_or_else(|| invalid("tail", "Effect duration overflow"))?,
329            );
330        }
331        if self.start < Time::ZERO
332            || duration <= Duration::ZERO
333            || duration.as_seconds() > 86400.0
334            || self
335                .start
336                .checked_add(duration)
337                .is_none_or(|end| end.ticks() > 86400 * crate::TICKS_PER_SECOND)
338            || !self.volume.is_finite()
339            || !(0.0..=16.0).contains(&self.volume)
340            || !self.pan.is_finite()
341            || !(-1.0..=1.0).contains(&self.pan)
342            || !self.stereo_width.is_finite()
343            || !(0.0..=2.0).contains(&self.stereo_width)
344            || self
345                .bus
346                .as_ref()
347                .is_some_and(|name| name.is_empty() || name.len() > 128)
348            || self.fade_in > duration
349            || self.fade_out > duration
350            || !(1..=192_000).contains(&self.source.sample_rate())
351            || !(1..=2).contains(&self.source.channels())
352        {
353            return Err(invalid(
354                "clip",
355                "Invalid audio timing, format, volume, pan or fade",
356            ));
357        }
358        Ok(duration)
359    }
360
361    /// Exclusive destination end, checked without reading samples.
362    pub fn end(&self) -> Result<Time, ValidationError> {
363        let duration = self.resolved_duration()?;
364        Ok(self.start + self.limit.map_or(duration, |limit| limit.min(duration)))
365    }
366
367    /// Places a nested audio occurrence in a parent timeline, cropping its audible window.
368    /// Source offsets and fade positions remain anchored to the original clip.
369    pub fn relocated(
370        &self,
371        offset: Duration,
372        window: Duration,
373    ) -> Result<Option<Self>, ValidationError> {
374        if !self.unresolved_file() {
375            self.resolved_duration()?;
376        }
377        let start = self
378            .start
379            .duration_since(Time::ZERO)
380            .ok_or_else(|| invalid("clip", "Audio start must be nonnegative"))?;
381        if start >= window {
382            return Ok(None);
383        }
384        let mut clip = self.clone();
385        let mut ducking = Vec::new();
386        for item in self.ducking.iter() {
387            if let DuckTrigger::Clip(trigger) = &item.trigger
388                && let Some(trigger) = trigger.relocated(offset, window)?
389            {
390                ducking.push(Ducking {
391                    trigger: DuckTrigger::Clip(Arc::new(trigger)),
392                    duck: item.duck,
393                });
394            }
395        }
396        clip.ducking = Arc::new(ducking);
397        let remaining = window - start;
398        clip.limit = Some(clip.limit.map_or(remaining, |limit| limit.min(remaining)));
399        clip.start = Time::ZERO
400            .checked_add(offset)
401            .and_then(|time| time.checked_add(start))
402            .ok_or_else(|| invalid("nested_audio", "Nested audio placement overflow"))?;
403        if !clip.unresolved_file() {
404            clip.resolved_duration()?;
405        }
406        Ok(Some(clip))
407    }
408}
409
410struct CompositionSource(crate::Timeline);
411
412struct SpatialPan {
413    position: Arc<dyn crate::Signal<crate::Point>>,
414    listener: crate::Point,
415    width: f64,
416}
417
418impl crate::Signal<f64> for SpatialPan {
419    fn sample(&self, time: Time) -> f64 {
420        let point = self.position.sample(time);
421        if !point.x.is_finite() || !point.y.is_finite() {
422            return f64::NAN;
423        }
424        ((point.x - self.listener.x) / self.width).clamp(-1.0, 1.0)
425    }
426}
427
428struct SpatialGain {
429    position: Arc<dyn crate::Signal<crate::Point>>,
430    listener: crate::Point,
431    width: f64,
432    rolloff: f64,
433    previous: Option<Arc<dyn crate::Signal<f64>>>,
434}
435
436impl crate::Signal<f64> for SpatialGain {
437    fn sample(&self, time: Time) -> f64 {
438        let point = self.position.sample(time);
439        if !point.x.is_finite() || !point.y.is_finite() {
440            return f64::NAN;
441        }
442        let distance =
443            libm::hypot(point.x - self.listener.x, point.y - self.listener.y) / self.width;
444        self.previous
445            .as_ref()
446            .map_or(1.0, |previous| previous.sample(time))
447            / (1.0 + self.rolloff * distance)
448    }
449}
450
451impl AudioSource for CompositionSource {
452    fn composition(&self) -> Option<&crate::Timeline> {
453        Some(&self.0)
454    }
455
456    fn sample_rate(&self) -> u32 {
457        48000
458    }
459
460    fn channels(&self) -> u16 {
461        2
462    }
463
464    fn duration(&self) -> Option<Duration> {
465        Some(self.0.duration())
466    }
467
468    fn read(&self, _: u64, output: &mut [f32]) -> usize {
469        output.fill(0.0);
470        0
471    }
472
473    fn try_read(&self, _: u64, _: &mut [f32]) -> crate::Result<usize> {
474        Err(std::io::Error::new(
475            std::io::ErrorKind::Unsupported,
476            "Deferred composition requires audio preparation",
477        )
478        .into())
479    }
480}
481
482struct FileReference(std::path::PathBuf);
483
484impl AudioSource for FileReference {
485    fn file_path(&self) -> Option<&std::path::Path> {
486        Some(&self.0)
487    }
488
489    fn sample_rate(&self) -> u32 {
490        48_000
491    }
492
493    fn channels(&self) -> u16 {
494        2
495    }
496
497    fn read(&self, _: u64, output: &mut [f32]) -> usize {
498        output.fill(0.0);
499        0
500    }
501
502    fn try_read(&self, _: u64, _: &mut [f32]) -> crate::Result<usize> {
503        Err(crate::Error::asset(
504            "audio",
505            self.0.display().to_string(),
506            std::io::Error::new(
507                std::io::ErrorKind::InvalidInput,
508                "File audio must resolve at an execution boundary",
509            ),
510        ))
511    }
512
513    fn duration(&self) -> Option<Duration> {
514        None
515    }
516}