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