Skip to main content

alpha2_eval/
audio.rs

1use crate::{ActiveIndex, EvalError, IndexedClip, collect_overlapping, count_overlapping};
2use alpha2_media::SourceId;
3use alpha2_model::{AudioProperties, ClipId, Project, TimeMap, TrackId};
4use alpha2_time::{MediaTime, TimeRange};
5
6/// One audio clip overlapping an evaluated timeline span.
7///
8/// Identities and authored properties are borrowed from the
9/// [`crate::ActiveIndex`] that produced the span.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct ActiveAudioClip<'a> {
12    /// Active clip identity.
13    pub clip_id: &'a ClipId,
14    /// Media source needed for this clip.
15    pub source_id: &'a SourceId,
16    /// Track containing the clip.
17    pub track_id: &'a TrackId,
18    /// Position of the containing track in project order.
19    pub track_order: usize,
20    /// Position of the clip in its track's clip list.
21    pub clip_order: usize,
22    /// Whether the containing track composites this clip visually as well.
23    ///
24    /// A video clip with an audio stream is reported here and by
25    /// [`crate::evaluate`], so a host mixes its sound without a second clip.
26    pub visual_track: bool,
27    /// Whole timeline interval occupied by the clip.
28    pub timeline_range: TimeRange,
29    /// Whole source interval consumed by the clip.
30    pub source_range: TimeRange,
31    /// How the clip's timeline time maps onto its source range.
32    pub time_map: TimeMap,
33    /// Part of the evaluated span this clip covers.
34    pub active_range: TimeRange,
35    /// Authored presentation of the immutable project snapshot.
36    pub audio: &'a AudioProperties,
37}
38
39impl ActiveAudioClip<'_> {
40    /// Returns the rate at which the clip consumes its source, forward or
41    /// backward: the source-to-timeline duration ratio, one for a loop, and
42    /// zero for a frozen clip.
43    ///
44    /// One means untouched playback. Two consumes source twice as fast, which
45    /// raises pitch by an octave because the mixer resamples rather than
46    /// time-stretches.
47    #[must_use]
48    pub fn speed(&self) -> f64 {
49        match self.time_map {
50            TimeMap::Loop { .. } => return 1.0,
51            TimeMap::Freeze { .. } => return 0.0,
52            TimeMap::Forward | TimeMap::Reverse => {}
53        }
54        let timeline = self.timeline_range.duration().map_or(0, MediaTime::ticks);
55        let source = self.source_range.duration().map_or(0, MediaTime::ticks);
56        if timeline <= 0 || source <= 0 {
57            return 1.0;
58        }
59        #[allow(clippy::cast_precision_loss)]
60        {
61            source as f64 / timeline as f64
62        }
63    }
64
65    /// Maps a timeline timestamp inside [`Self::timeline_range`] to source time.
66    #[must_use]
67    pub fn source_time_at(&self, time: MediaTime) -> Option<MediaTime> {
68        crate::map_source_time(
69            self.timeline_range,
70            self.source_range,
71            self.time_map,
72            crate::Mirror::TICK,
73            time,
74        )
75    }
76}
77
78/// Audio activity over one half-open timeline span.
79#[derive(Clone, Debug, PartialEq)]
80pub struct AudioSpanDesc<'a> {
81    /// Span evaluated on the project timeline.
82    pub range: TimeRange,
83    /// Clips overlapping the span, ordered by track, start time, and then
84    /// position in the track.
85    pub clips: Vec<ActiveAudioClip<'a>>,
86}
87
88/// Evaluates every audible clip overlapping a half-open timeline span.
89///
90/// Clips on audio tracks always qualify. A clip on a visual track qualifies
91/// when its source declares an audio stream, so one clip carries both picture
92/// and sound. Overlapping clips are all reported; the mixer sums them.
93///
94/// The span is the mixer's block, so clips are reported with their whole
95/// authored ranges plus the part of the span they cover. Gain, pan, and fade
96/// curves stay authored rather than sampled here; a mixer evaluates them per
97/// sample so that automation is independent of the host block size.
98///
99/// The span borrows identities and properties from `index`. It allocates its
100/// working list and its clip list, each sized exactly; a caller evaluating
101/// span after span uses [`evaluate_audio_in`] to allocate nothing.
102///
103/// # Errors
104/// Returns [`EvalError::IndexProjectMismatch`] when the index was built for another
105/// project, and [`EvalError::UnmappableClip`] for unrepresentable retiming.
106pub fn evaluate_audio<'a>(
107    project: &Project,
108    index: &'a ActiveIndex,
109    range: TimeRange,
110) -> Result<AudioSpanDesc<'a>, EvalError> {
111    crate::ensure_index_matches(project, index)?;
112    let capacity = index.root.map_or(0, |root| {
113        count_overlapping(&index.nodes, root, range, &|entry| entry.source_is_audible)
114    });
115    let mut scratch = AudioSpanScratch {
116        nodes: Vec::with_capacity(capacity),
117    };
118    let span = fill_span(index, range, &mut scratch)?;
119    Ok(AudioSpanDesc {
120        range,
121        clips: span.iter().collect(),
122    })
123}
124
125/// Reusable working memory for [`evaluate_audio_in`].
126///
127/// It holds positions in an index rather than borrows of one, so a mixer keeps
128/// a single scratch for its whole life, across snapshots. Once it has held as
129/// many clips as a span needs, evaluating another span allocates nothing.
130#[derive(Clone, Debug, Default)]
131pub struct AudioSpanScratch {
132    nodes: Vec<usize>,
133}
134
135/// The clips of one span, as [`evaluate_audio`] reports them, held in an
136/// [`AudioSpanScratch`] and read back without copying.
137#[derive(Clone, Copy, Debug)]
138pub struct AudioSpan<'a, 's> {
139    index: &'a ActiveIndex,
140    range: TimeRange,
141    nodes: &'s [usize],
142}
143
144impl<'a> AudioSpan<'a, '_> {
145    /// Span evaluated on the project timeline.
146    #[must_use]
147    pub const fn range(&self) -> TimeRange {
148        self.range
149    }
150
151    /// Number of clips in the span.
152    #[must_use]
153    pub const fn len(&self) -> usize {
154        self.nodes.len()
155    }
156
157    /// Whether no audible clip overlaps the span.
158    #[must_use]
159    pub const fn is_empty(&self) -> bool {
160        self.nodes.is_empty()
161    }
162
163    /// The clips in mix order: by track, start time, and then position in
164    /// the track.
165    #[must_use = "iterators are lazy and do nothing unless consumed"]
166    pub fn iter(&self) -> impl ExactSizeIterator<Item = ActiveAudioClip<'a>> {
167        let (index, range) = (self.index, self.range);
168        self.nodes
169            .iter()
170            .map(move |&node| active_clip(index.nodes[node].entry(), range))
171    }
172}
173
174/// Evaluates the same clips as [`evaluate_audio`] into reusable memory.
175///
176/// # Errors
177/// Returns any error emitted by [`evaluate_audio`].
178pub fn evaluate_audio_in<'a, 's>(
179    project: &Project,
180    index: &'a ActiveIndex,
181    range: TimeRange,
182    scratch: &'s mut AudioSpanScratch,
183) -> Result<AudioSpan<'a, 's>, EvalError> {
184    crate::ensure_index_matches(project, index)?;
185    fill_span(index, range, scratch)
186}
187
188/// Collects the nodes of every audible, mappable clip overlapping `range`,
189/// sorted into mix order.
190fn fill_span<'a, 's>(
191    index: &'a ActiveIndex,
192    range: TimeRange,
193    scratch: &'s mut AudioSpanScratch,
194) -> Result<AudioSpan<'a, 's>, EvalError> {
195    let nodes = &mut scratch.nodes;
196    nodes.clear();
197    let mix_order = |entry: &IndexedClip| {
198        (
199            entry.track_order,
200            entry.timeline_range.start(),
201            entry.clip_order,
202        )
203    };
204    // The first clip in mix order that cannot be mapped, if any.
205    let mut unmappable: Option<&IndexedClip> = None;
206    if let Some(root) = index.root {
207        collect_overlapping(&index.nodes, root, range, &mut |node, entry| {
208            // Audio-track clips always qualify; a visual clip needs a source that declares an
209            // audio stream.
210            if !entry.source_is_audible {
211                return;
212            }
213            let Some(active_range) = entry.timeline_range.intersection(range) else {
214                return;
215            };
216            if active_range.is_empty() {
217                return;
218            }
219            // Reject retiming the mixer could not map, matching visual evaluation.
220            if crate::map_source_time(
221                entry.timeline_range,
222                entry.source_range,
223                entry.time_map,
224                crate::Mirror::TICK,
225                active_range.start(),
226            )
227            .is_none()
228            {
229                if unmappable.is_none_or(|first| mix_order(entry) < mix_order(first)) {
230                    unmappable = Some(entry);
231                }
232                return;
233            }
234            nodes.push(node);
235        });
236    }
237    if let Some(entry) = unmappable {
238        return Err(EvalError::UnmappableClip {
239            clip: entry.clip_id.clone(),
240        });
241    }
242    nodes.sort_unstable_by_key(|&node| mix_order(index.nodes[node].entry()));
243    Ok(AudioSpan {
244        index,
245        range,
246        nodes,
247    })
248}
249
250/// Describes one clip that [`fill_span`] kept: it overlaps `range`.
251fn active_clip(entry: &IndexedClip, range: TimeRange) -> ActiveAudioClip<'_> {
252    ActiveAudioClip {
253        clip_id: &entry.clip_id,
254        source_id: &entry.source_id,
255        track_id: &entry.track_id,
256        track_order: entry.track_order,
257        clip_order: entry.clip_order,
258        visual_track: entry.track_kind == alpha2_model::TrackKind::Video,
259        timeline_range: entry.timeline_range,
260        source_range: entry.source_range,
261        time_map: entry.time_map,
262        active_range: entry.timeline_range.intersection(range).unwrap_or(range),
263        audio: &entry.audio,
264    }
265}