motion-core 0.1.0

Backend-independent motion engine primitives
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
//! Tick-authored audio clip occurrences, their placement parameters and internal sources.

use super::{AudioEffect, AudioSource, Duck, DuckTrigger, Ducking, PanLaw, invalid};
use crate::{Duration, Time, ValidationError};
use std::sync::Arc;

/// tick-authored audio occurrence, separate from the retained root AudioClip.
#[derive(Clone)]
pub struct AudioClip {
    source: Arc<dyn AudioSource>,
    start: Time,
    offset: Time,
    duration: Option<Duration>,
    volume: f64,
    fade_in: Duration,
    fade_out: Duration,
    pan: f64,
    envelope: Option<Arc<dyn crate::Animation<f64>>>,
    limit: Option<Duration>,
    effects: Arc<Vec<Arc<dyn AudioEffect>>>,
    pan_animation: Option<Arc<dyn crate::Animation<f64>>>,
    pan_law: PanLaw,
    stereo_width: f64,
    bus: Option<String>,
    ducking: Arc<Vec<Ducking>>,
}

impl<Source: AudioSource + ?Sized + 'static> From<Arc<Source>> for AudioClip {
    fn from(source: Arc<Source>) -> Self {
        Self::source(source)
    }
}

/// Immutable placement and envelope inputs consumed by audio mixers.
pub struct AudioClipParameters<'a> {
    /// Stateless source captured by this occurrence.
    pub source: &'a dyn AudioSource,
    /// Destination start on the local timeline.
    pub start: Time,
    /// First source sample time.
    pub offset: Time,
    /// Authored duration override, absent when natural file duration is unresolved.
    pub duration: Option<Duration>,
    /// Optional containing-scene crop.
    pub limit: Option<Duration>,
    /// Linear amplitude multiplier.
    pub volume: f64,
    /// Fade-in duration.
    pub fade_in: Duration,
    /// Fade-out duration.
    pub fade_out: Duration,
    /// Optional pure local-time gain animation.
    pub envelope: Option<&'a dyn crate::Animation<f64>>,
    /// Mono/stereo pan setting.
    pub pan: f64,
    /// Optional local-time pan animation, replacing the constant pan.
    pub pan_animation: Option<&'a dyn crate::Animation<f64>>,
    /// Stereo balance law.
    pub pan_law: PanLaw,
    /// Mid/side width in 0..=2; one preserves the stereo source.
    pub stereo_width: f64,
    /// Effects applied at native rate before gain and pan.
    pub effects: &'a [Arc<dyn AudioEffect>],
    /// Optional named submix.
    pub bus: Option<&'a str>,
    /// Sidechain ducking by other clips, applied after gain and pan.
    pub ducking: &'a [Ducking],
}

impl std::fmt::Debug for AudioClip {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("AudioClip")
            .field("start", &self.start)
            .field("offset", &self.offset)
            .field("duration", &self.duration)
            .field("volume", &self.volume)
            .field("pan", &self.pan)
            .finish_non_exhaustive()
    }
}

impl AudioClip {
    /// Retains a nested mix, including its own buses and master, without decoding.
    pub fn composition(timeline: crate::Timeline) -> Self {
        Self::source(CompositionSource(timeline))
    }

    /// Retains a local file path without opening, probing or decoding it.
    pub fn file(path: impl Into<std::path::PathBuf>) -> Self {
        Self::source(FileReference(path.into()))
    }

    /// Replaces a deferred source at an execution boundary, preserving all authored settings.
    pub fn with_resolved_source(&self, source: Arc<dyn AudioSource>) -> Self {
        let mut clip = self.clone();
        clip.source = source;
        clip
    }

    /// Whether metadata is still required to determine a natural file duration.
    pub fn unresolved_file(&self) -> bool {
        self.source.file_path().is_some() && self.source.duration().is_none()
    }

    /// Borrows mixer parameters without reading, decoding or copying PCM.
    pub fn parameters(&self) -> AudioClipParameters<'_> {
        AudioClipParameters {
            source: self.source.as_ref(),
            start: self.start,
            offset: self.offset,
            duration: self.duration,
            limit: self.limit,
            volume: self.volume,
            fade_in: self.fade_in,
            fade_out: self.fade_out,
            envelope: self.envelope.as_deref(),
            pan: self.pan,
            pan_animation: self.pan_animation.as_deref(),
            pan_law: self.pan_law,
            stereo_width: self.stereo_width,
            effects: &self.effects,
            bus: self.bus.as_deref(),
            ducking: &self.ducking,
        }
    }

    /// Captures a stateless source. Infinite sources require an explicit duration.
    pub fn source(source: impl AudioSource + 'static) -> Self {
        Self {
            source: Arc::new(source),
            start: Time::ZERO,
            offset: Time::ZERO,
            duration: None,
            volume: 1.0,
            fade_in: Time::ZERO,
            fade_out: Time::ZERO,
            pan: 0.0,
            envelope: None,
            limit: None,
            effects: Arc::new(Vec::new()),
            pan_animation: None,
            pan_law: PanLaw::ConstantPower,
            stereo_width: 1.0,
            bus: None,
            ducking: Arc::new(Vec::new()),
        }
    }

    /// Sets destination start in nonnegative ticks.
    pub fn start(mut self, start: Time) -> Self {
        self.start = start;
        self
    }

    /// Sets source offset, rounded once to the nearest source-rate frame.
    pub fn offset(mut self, offset: Time) -> Self {
        self.offset = offset;
        self
    }

    /// Sets a finite positive occurrence duration; EOF is padded with silence.
    pub fn duration(mut self, duration: Duration) -> Self {
        self.duration = Some(duration);
        self
    }

    /// Sets finite amplitude in 0..=16; mixing never normalizes peaks automatically.
    pub fn volume(mut self, volume: f64) -> Self {
        self.volume = volume;
        self
    }

    /// Multiplies volume by a typed animation sampled at clip-local output time.
    pub fn volume_envelope(mut self, envelope: impl crate::Animation<f64> + 'static) -> Self {
        self.envelope = Some(Arc::new(envelope));
        self
    }

    /// Linear amplitude fade anchored to the clip start; overlapping fades multiply.
    pub fn fade_in(mut self, duration: Duration) -> Self {
        self.fade_in = duration;
        self
    }

    /// Linear amplitude fade ending at the occurrence's exclusive end.
    pub fn fade_out(mut self, duration: Duration) -> Self {
        self.fade_out = duration;
        self
    }

    /// Constant-power mono pan; stereo uses balance. Range -1 left through +1 right.
    pub fn pan(mut self, pan: f64) -> Self {
        self.pan = pan;
        self
    }

    /// Appends an offline effect, rendered once from the clip origin.
    pub fn effect(mut self, effect: impl AudioEffect + 'static) -> Self {
        Arc::make_mut(&mut self.effects).push(Arc::new(effect));
        self
    }

    /// Lowers this clip while `trigger` is audible. The trigger is still mixed on its own
    /// when it is also added to the timeline; at most four triggers per clip. Ducking is
    /// rendered offline from the trigger envelope, so it is deterministic and random-access safe.
    pub fn ducked_by(mut self, trigger: &AudioClip, duck: Duck) -> Self {
        let mut trigger = trigger.clone();
        trigger.ducking = Arc::new(Vec::new());
        Arc::make_mut(&mut self.ducking).push(Ducking {
            trigger: DuckTrigger::Clip(Arc::new(trigger)),
            duck,
        });
        self
    }

    /// Routes the occurrence to a named timeline bus.
    pub fn bus(mut self, name: impl Into<String>) -> Self {
        self.bus = Some(name.into());
        self
    }

    /// Replaces stereo balance law; mono panning is unaffected.
    pub fn pan_law(mut self, law: PanLaw) -> Self {
        self.pan_law = law;
        self
    }

    /// Sets a pure local-time pan animation, with values in -1..=1.
    pub fn pan_animation(mut self, animation: impl crate::Animation<f64> + 'static) -> Self {
        self.pan_animation = Some(Arc::new(animation));
        self
    }

    /// Sets mid/side width in 0..=2, without changing the mono component.
    pub fn stereo_width(mut self, width: f64) -> Self {
        self.stereo_width = width;
        self
    }

    /// Couples screen position to pan and inverse-distance gain, multiplying the existing envelope.
    /// Width is the listener-to-edge distance; this is not HRTF or three-dimensional spatial audio.
    pub fn spatial(
        mut self,
        position: impl crate::Animation<crate::Point> + 'static,
        listener: crate::Point,
        width: f64,
        rolloff: f64,
    ) -> Result<Self, ValidationError> {
        if !listener.x.is_finite()
            || !listener.y.is_finite()
            || !width.is_finite()
            || width <= 0.0
            || !rolloff.is_finite()
            || !(0.0..=1000.0).contains(&rolloff)
        {
            return Err(invalid(
                "spatial",
                "Invalid listener, width or distance rolloff",
            ));
        }
        let position: Arc<dyn crate::Animation<crate::Point>> = Arc::new(position);
        self.pan_animation = Some(Arc::new(SpatialPan {
            position: position.clone(),
            listener,
            width,
        }));
        self.envelope = Some(Arc::new(SpatialGain {
            position,
            listener,
            width,
            rolloff,
            previous: self.envelope.take(),
        }));
        Ok(self)
    }

    /// Selects a positive source-time interval; natural duration is not required.
    pub fn trim(self, start: Time, end: Time) -> Self {
        self.offset(start).duration(end - start)
    }

    /// Validated natural/overridden duration, before destination placement.
    pub fn resolved_duration(&self) -> Result<Duration, ValidationError> {
        let mut duration = self
            .duration
            .or_else(|| {
                self.source.duration().and_then(|duration| {
                    duration
                        .ticks()
                        .checked_sub(self.offset.ticks())
                        .map(Time::from_ticks)
                })
            })
            .ok_or_else(|| invalid("duration", "Unbounded audio requires an explicit duration"))?;
        if duration <= Time::ZERO {
            return Err(invalid(
                "duration",
                "Clip source duration must be positive before effect tails",
            ));
        }
        if self.effects.len() > 16 {
            return Err(invalid("effects", "At most sixteen effects per clip"));
        }
        if self.ducking.len() > 4 {
            return Err(invalid("ducking", "At most four ducking triggers per clip"));
        }
        for ducking in self.ducking.iter() {
            ducking.duck.validate()?;
            match &ducking.trigger {
                DuckTrigger::Clip(trigger) if !trigger.unresolved_file() => {
                    trigger.resolved_duration()?;
                }
                DuckTrigger::Clip(_) => {}
                DuckTrigger::Bus(_) => {
                    return Err(invalid("ducking", "Clip ducking requires a clip trigger"));
                }
            }
        }
        for effect in self.effects.iter() {
            effect.validate(self.source.sample_rate())?;
            let tail = effect.tail();
            if tail < Time::ZERO || tail.as_seconds() > 30.0 {
                return Err(invalid("tail", "Effect tail must be in 0..=30 seconds"));
            }
            duration = Time::from_ticks(
                duration
                    .ticks()
                    .checked_add(tail.ticks())
                    .ok_or_else(|| invalid("tail", "Effect duration overflow"))?,
            );
        }
        if self.start < Time::ZERO
            || self.offset < Time::ZERO
            || duration <= Time::ZERO
            || duration.as_seconds() > 86400.0
            || self
                .start
                .ticks()
                .checked_add(duration.ticks())
                .is_none_or(|end| end > 86400 * crate::TICKS_PER_SECOND)
            || !self.volume.is_finite()
            || !(0.0..=16.0).contains(&self.volume)
            || !self.pan.is_finite()
            || !(-1.0..=1.0).contains(&self.pan)
            || !self.stereo_width.is_finite()
            || !(0.0..=2.0).contains(&self.stereo_width)
            || self
                .bus
                .as_ref()
                .is_some_and(|name| name.is_empty() || name.len() > 128)
            || self.fade_in < Time::ZERO
            || self.fade_out < Time::ZERO
            || self.fade_in > duration
            || self.fade_out > duration
            || !(1..=192_000).contains(&self.source.sample_rate())
            || !(1..=2).contains(&self.source.channels())
        {
            return Err(invalid(
                "clip",
                "Invalid audio timing, format, volume, pan or fade",
            ));
        }
        Ok(duration)
    }

    /// Exclusive destination end, checked without reading samples.
    pub fn end(&self) -> Result<Time, ValidationError> {
        let duration = self.resolved_duration()?;
        Ok(self.start + self.limit.map_or(duration, |limit| limit.min(duration)))
    }

    /// Places a nested audio occurrence in a parent timeline, cropping its audible window.
    /// Source offsets and fade positions remain anchored to the original clip.
    pub fn relocated(
        &self,
        offset: Time,
        window: Duration,
    ) -> Result<Option<Self>, ValidationError> {
        if !self.unresolved_file() {
            self.resolved_duration()?;
        }
        if self.start >= window {
            return Ok(None);
        }
        let mut clip = self.clone();
        let mut ducking = Vec::new();
        for item in self.ducking.iter() {
            if let DuckTrigger::Clip(trigger) = &item.trigger
                && let Some(trigger) = trigger.relocated(offset, window)?
            {
                ducking.push(Ducking {
                    trigger: DuckTrigger::Clip(Arc::new(trigger)),
                    duck: item.duck,
                });
            }
        }
        clip.ducking = Arc::new(ducking);
        let remaining = window - clip.start;
        clip.limit = Some(clip.limit.map_or(remaining, |limit| limit.min(remaining)));
        clip.start = Time::from_ticks(
            offset
                .ticks()
                .checked_add(clip.start.ticks())
                .ok_or_else(|| invalid("nested_audio", "Nested audio placement overflow"))?,
        );
        if !clip.unresolved_file() {
            clip.resolved_duration()?;
        }
        Ok(Some(clip))
    }
}

struct CompositionSource(crate::Timeline);

struct SpatialPan {
    position: Arc<dyn crate::Animation<crate::Point>>,
    listener: crate::Point,
    width: f64,
}

impl crate::Animation<f64> for SpatialPan {
    fn sample(&self, time: Time) -> f64 {
        let point = self.position.sample(time);
        if !point.x.is_finite() || !point.y.is_finite() {
            return f64::NAN;
        }
        ((point.x - self.listener.x) / self.width).clamp(-1.0, 1.0)
    }

    fn duration(&self) -> Option<Duration> {
        self.position.duration()
    }
}

struct SpatialGain {
    position: Arc<dyn crate::Animation<crate::Point>>,
    listener: crate::Point,
    width: f64,
    rolloff: f64,
    previous: Option<Arc<dyn crate::Animation<f64>>>,
}

impl crate::Animation<f64> for SpatialGain {
    fn sample(&self, time: Time) -> f64 {
        let point = self.position.sample(time);
        if !point.x.is_finite() || !point.y.is_finite() {
            return f64::NAN;
        }
        let distance =
            libm::hypot(point.x - self.listener.x, point.y - self.listener.y) / self.width;
        self.previous
            .as_ref()
            .map_or(1.0, |previous| previous.sample(time))
            / (1.0 + self.rolloff * distance)
    }

    fn duration(&self) -> Option<Duration> {
        self.position.duration()
    }
}

impl AudioSource for CompositionSource {
    fn composition(&self) -> Option<&crate::Timeline> {
        Some(&self.0)
    }

    fn sample_rate(&self) -> u32 {
        48000
    }

    fn channels(&self) -> u16 {
        2
    }

    fn duration(&self) -> Option<Duration> {
        Some(self.0.total_duration())
    }

    fn read(&self, _: u64, output: &mut [f32]) -> usize {
        output.fill(0.0);
        0
    }

    fn try_read(&self, _: u64, _: &mut [f32]) -> crate::Result<usize> {
        Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "Deferred composition requires audio preparation",
        )
        .into())
    }
}

struct FileReference(std::path::PathBuf);

impl AudioSource for FileReference {
    fn file_path(&self) -> Option<&std::path::Path> {
        Some(&self.0)
    }

    fn sample_rate(&self) -> u32 {
        48_000
    }

    fn channels(&self) -> u16 {
        2
    }

    fn read(&self, _: u64, output: &mut [f32]) -> usize {
        output.fill(0.0);
        0
    }

    fn try_read(&self, _: u64, _: &mut [f32]) -> crate::Result<usize> {
        Err(crate::Error::asset(
            "audio",
            self.0.display().to_string(),
            std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "File audio must resolve at an execution boundary",
            ),
        ))
    }

    fn duration(&self) -> Option<Duration> {
        None
    }
}