Skip to main content

lumen_engine/
audio.rs

1//! Audio timeline, source, and mixing primitives.
2
3use std::{collections::HashMap, sync::Arc};
4
5use crate::error::MediaError;
6
7pub const AUDIO_SAMPLE_RATE: u32 = 48_000;
8pub const AUDIO_CHANNELS: usize = 2;
9const CLIP_EDGE_FADE_SAMPLES: u64 = 384;
10
11#[derive(Debug, Clone, PartialEq)]
12pub struct AudioTimeline {
13    pub tracks: Vec<AudioTrack>,
14    pub clips: Vec<AudioClip>,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct AudioTrack {
19    pub id: String,
20    pub name: String,
21    pub muted: bool,
22    pub solo: bool,
23    pub volume: f32,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct AudioClip {
28    pub id: String,
29    pub source_id: String,
30    pub track_id: String,
31    pub name: String,
32    pub start_ms: u64,
33    pub duration_ms: u64,
34    pub source_start_ms: u64,
35    pub volume: f32,
36}
37
38#[derive(Debug, Clone, Copy, Default, PartialEq)]
39pub struct AudioMetadata {
40    pub sample_rate: u32,
41    pub channels: u16,
42    pub duration_samples: u64,
43}
44
45#[derive(Debug, Clone, PartialEq)]
46pub struct AudioBuffer {
47    channels: Vec<Vec<f32>>,
48    sample_rate: u32,
49}
50
51impl AudioBuffer {
52    pub fn silent(sample_rate: u32, channel_count: usize, frames: usize) -> Self {
53        Self {
54            channels: vec![vec![0.0; frames]; channel_count],
55            sample_rate,
56        }
57    }
58
59    pub fn from_channels(sample_rate: u32, channels: Vec<Vec<f32>>) -> Self {
60        Self {
61            sample_rate,
62            channels,
63        }
64    }
65
66    pub fn sample_rate(&self) -> u32 {
67        self.sample_rate
68    }
69
70    pub fn channel_count(&self) -> usize {
71        self.channels.len()
72    }
73
74    pub fn frames(&self) -> usize {
75        self.channels.first().map_or(0, Vec::len)
76    }
77
78    pub fn channels(&self) -> &[Vec<f32>] {
79        &self.channels
80    }
81
82    pub fn channels_mut(&mut self) -> &mut [Vec<f32>] {
83        &mut self.channels
84    }
85
86    pub fn interleaved_f32(&self) -> Vec<f32> {
87        let frames = self.frames();
88        let channels = self.channel_count();
89        let mut output = Vec::with_capacity(frames.saturating_mul(channels));
90        for frame in 0..frames {
91            for channel in 0..channels {
92                output.push(self.channels[channel][frame]);
93            }
94        }
95        output
96    }
97}
98
99pub trait AudioResolver: Send + Sync {
100    fn id(&self) -> &str;
101
102    fn metadata(&self) -> AudioMetadata;
103
104    fn resolve_range(
105        &self,
106        start_sample: u64,
107        frames: usize,
108    ) -> Result<Arc<AudioBuffer>, MediaError>;
109}
110
111pub trait AudioSourceProvider {
112    fn get_audio_resolver(&self, source_id: &str) -> Option<Box<dyn AudioResolver>>;
113}
114
115pub struct AudioMixer<'a, P: AudioSourceProvider> {
116    timeline: &'a AudioTimeline,
117    provider: &'a P,
118}
119
120impl<'a, P: AudioSourceProvider> AudioMixer<'a, P> {
121    pub fn new(timeline: &'a AudioTimeline, provider: &'a P) -> Self {
122        Self { timeline, provider }
123    }
124
125    pub fn mix_range(&self, start_sample: u64, frames: usize) -> Result<AudioBuffer, MediaError> {
126        let mut output = AudioBuffer::silent(AUDIO_SAMPLE_RATE, AUDIO_CHANNELS, frames);
127        self.mix_into(start_sample, output.channels_mut())?;
128        Ok(output)
129    }
130
131    pub fn mix_into(&self, start_sample: u64, output: &mut [Vec<f32>]) -> Result<(), MediaError> {
132        let output_frames = output.first().map_or(0, Vec::len);
133        if output_frames == 0 || output.is_empty() {
134            return Ok(());
135        }
136
137        for channel in output.iter_mut() {
138            channel.fill(0.0);
139        }
140
141        let has_solo = self.timeline.tracks.iter().any(|track| track.solo);
142        let tracks = self
143            .timeline
144            .tracks
145            .iter()
146            .map(|track| (track.id.as_str(), track))
147            .collect::<HashMap<_, _>>();
148
149        for clip in &self.timeline.clips {
150            let Some(track) = tracks.get(clip.track_id.as_str()) else {
151                continue;
152            };
153            if track.muted || (has_solo && !track.solo) || track.volume <= 0.0 || clip.volume <= 0.0
154            {
155                continue;
156            }
157
158            let clip_start = ms_to_sample(clip.start_ms);
159            let clip_duration = ms_to_sample(clip.duration_ms).max(1);
160            let clip_end = clip_start.saturating_add(clip_duration);
161            let output_end = start_sample.saturating_add(output_frames as u64);
162            let overlap_start = start_sample.max(clip_start);
163            let overlap_end = output_end.min(clip_end);
164            if overlap_end <= overlap_start {
165                continue;
166            }
167
168            let frames_to_mix = usize::try_from(overlap_end - overlap_start).unwrap_or(0);
169            let output_offset = usize::try_from(overlap_start - start_sample).unwrap_or(0);
170            let clip_offset = overlap_start - clip_start;
171            let source_start = ms_to_sample(clip.source_start_ms).saturating_add(clip_offset);
172            let source = self
173                .provider
174                .get_audio_resolver(&clip.source_id)
175                .ok_or_else(|| MediaError::SourceNotFound {
176                    media_source: clip.source_id.clone(),
177                })?;
178            let source_buffer = source.resolve_range(source_start, frames_to_mix)?;
179            let gain = track.volume * clip.volume;
180
181            for i in 0..frames_to_mix.min(source_buffer.frames()) {
182                let clip_sample = clip_offset.saturating_add(i as u64);
183                let envelope = clip_envelope(clip_sample, clip_duration);
184                let sample_gain = gain * envelope;
185                for (out_channel_index, output_channel) in output.iter_mut().enumerate() {
186                    let source_sample =
187                        sample_for_output_channel(&source_buffer, out_channel_index, i);
188                    let Some(output_sample) = output_channel.get_mut(output_offset + i) else {
189                        continue;
190                    };
191                    *output_sample += source_sample * sample_gain;
192                }
193            }
194        }
195
196        for channel in output {
197            for sample in channel {
198                *sample = sample.clamp(-1.0, 1.0);
199            }
200        }
201
202        Ok(())
203    }
204}
205
206pub fn ms_to_sample(ms: u64) -> u64 {
207    ((ms as u128 * u128::from(AUDIO_SAMPLE_RATE)) / 1_000) as u64
208}
209
210pub fn duration_samples(duration_frames: u32, fps: f32) -> u64 {
211    if fps <= 0.0 {
212        return 0;
213    }
214    ((duration_frames as f64 / fps as f64) * f64::from(AUDIO_SAMPLE_RATE)).round() as u64
215}
216
217fn sample_for_output_channel(buffer: &AudioBuffer, output_channel: usize, frame: usize) -> f32 {
218    match buffer.channel_count() {
219        0 => 0.0,
220        1 => buffer.channels()[0].get(frame).copied().unwrap_or(0.0),
221        count => buffer.channels()[output_channel.min(count - 1)]
222            .get(frame)
223            .copied()
224            .unwrap_or(0.0),
225    }
226}
227
228fn clip_envelope(clip_sample: u64, clip_duration: u64) -> f32 {
229    let fade = CLIP_EDGE_FADE_SAMPLES.min(clip_duration / 2);
230    if fade == 0 {
231        return 1.0;
232    }
233    if clip_sample < fade {
234        return clip_sample as f32 / fade as f32;
235    }
236    let remaining = clip_duration.saturating_sub(clip_sample);
237    if remaining < fade {
238        return remaining as f32 / fade as f32;
239    }
240    1.0
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[derive(Debug)]
248    struct TestProvider {
249        sources: HashMap<String, Arc<AudioBuffer>>,
250    }
251
252    impl AudioSourceProvider for TestProvider {
253        fn get_audio_resolver(&self, source_id: &str) -> Option<Box<dyn AudioResolver>> {
254            self.sources.get(source_id).cloned().map(|buffer| {
255                Box::new(TestResolver {
256                    id: source_id.to_string(),
257                    buffer,
258                }) as Box<dyn AudioResolver>
259            })
260        }
261    }
262
263    struct TestResolver {
264        id: String,
265        buffer: Arc<AudioBuffer>,
266    }
267
268    impl AudioResolver for TestResolver {
269        fn id(&self) -> &str {
270            &self.id
271        }
272
273        fn metadata(&self) -> AudioMetadata {
274            AudioMetadata {
275                sample_rate: self.buffer.sample_rate(),
276                channels: self.buffer.channel_count() as u16,
277                duration_samples: self.buffer.frames() as u64,
278            }
279        }
280
281        fn resolve_range(
282            &self,
283            start_sample: u64,
284            frames: usize,
285        ) -> Result<Arc<AudioBuffer>, MediaError> {
286            let start = usize::try_from(start_sample).unwrap_or(usize::MAX);
287            let end = start.saturating_add(frames).min(self.buffer.frames());
288            let mut channels = Vec::new();
289            for channel in self.buffer.channels() {
290                let mut out = vec![0.0; frames];
291                if start < channel.len() {
292                    let src = &channel[start..end];
293                    out[..src.len()].copy_from_slice(src);
294                }
295                channels.push(out);
296            }
297            Ok(Arc::new(AudioBuffer::from_channels(
298                self.buffer.sample_rate(),
299                channels,
300            )))
301        }
302    }
303
304    fn track(id: &str) -> AudioTrack {
305        AudioTrack {
306            id: id.to_string(),
307            name: id.to_string(),
308            muted: false,
309            solo: false,
310            volume: 1.0,
311        }
312    }
313
314    #[test]
315    fn mixes_single_clip_with_source_offset() {
316        let provider = TestProvider {
317            sources: HashMap::from([(
318                "tone".to_string(),
319                Arc::new(AudioBuffer::from_channels(
320                    AUDIO_SAMPLE_RATE,
321                    vec![vec![0.5; AUDIO_SAMPLE_RATE as usize]],
322                )),
323            )]),
324        };
325        let timeline = AudioTimeline {
326            tracks: vec![track("main")],
327            clips: vec![AudioClip {
328                id: "clip".to_string(),
329                source_id: "tone".to_string(),
330                track_id: "main".to_string(),
331                name: "tone".to_string(),
332                start_ms: 0,
333                duration_ms: 1_000,
334                source_start_ms: 0,
335                volume: 1.0,
336            }],
337        };
338
339        let mixed = AudioMixer::new(&timeline, &provider)
340            .mix_range(ms_to_sample(500), 5)
341            .unwrap();
342        assert_eq!(mixed.channel_count(), 2);
343        assert!(mixed.channels()[0][2] > 0.49);
344        assert_eq!(mixed.channels()[0], mixed.channels()[1]);
345    }
346
347    #[test]
348    fn applies_mute_solo_and_gain() {
349        let provider = TestProvider {
350            sources: HashMap::from([(
351                "tone".to_string(),
352                Arc::new(AudioBuffer::from_channels(
353                    AUDIO_SAMPLE_RATE,
354                    vec![
355                        vec![1.0; AUDIO_SAMPLE_RATE as usize],
356                        vec![0.5; AUDIO_SAMPLE_RATE as usize],
357                    ],
358                )),
359            )]),
360        };
361        let mut solo = track("solo");
362        solo.solo = true;
363        solo.volume = 0.5;
364        let mut muted = track("muted");
365        muted.muted = true;
366        let timeline = AudioTimeline {
367            tracks: vec![solo, muted],
368            clips: vec![
369                AudioClip {
370                    id: "a".to_string(),
371                    source_id: "tone".to_string(),
372                    track_id: "solo".to_string(),
373                    name: "a".to_string(),
374                    start_ms: 0,
375                    duration_ms: 1_000,
376                    source_start_ms: 0,
377                    volume: 0.5,
378                },
379                AudioClip {
380                    id: "b".to_string(),
381                    source_id: "tone".to_string(),
382                    track_id: "muted".to_string(),
383                    name: "b".to_string(),
384                    start_ms: 0,
385                    duration_ms: 1_000,
386                    source_start_ms: 0,
387                    volume: 1.0,
388                },
389            ],
390        };
391
392        let mixed = AudioMixer::new(&timeline, &provider)
393            .mix_range(500, 4)
394            .unwrap();
395        assert_eq!(mixed.channels()[0], vec![0.25; 4]);
396        assert_eq!(mixed.channels()[1], vec![0.125; 4]);
397    }
398
399    #[test]
400    fn overlaps_clips_and_clamps_output() {
401        let provider = TestProvider {
402            sources: HashMap::from([(
403                "tone".to_string(),
404                Arc::new(AudioBuffer::from_channels(
405                    AUDIO_SAMPLE_RATE,
406                    vec![vec![0.75; AUDIO_SAMPLE_RATE as usize]],
407                )),
408            )]),
409        };
410        let timeline = AudioTimeline {
411            tracks: vec![track("main")],
412            clips: vec![
413                AudioClip {
414                    id: "a".to_string(),
415                    source_id: "tone".to_string(),
416                    track_id: "main".to_string(),
417                    name: "a".to_string(),
418                    start_ms: 0,
419                    duration_ms: 1_000,
420                    source_start_ms: 0,
421                    volume: 1.0,
422                },
423                AudioClip {
424                    id: "b".to_string(),
425                    source_id: "tone".to_string(),
426                    track_id: "main".to_string(),
427                    name: "b".to_string(),
428                    start_ms: 0,
429                    duration_ms: 1_000,
430                    source_start_ms: 0,
431                    volume: 1.0,
432                },
433            ],
434        };
435
436        let mixed = AudioMixer::new(&timeline, &provider)
437            .mix_range(500, 4)
438            .unwrap();
439        assert_eq!(mixed.channels()[0], vec![1.0; 4]);
440        assert_eq!(mixed.channels()[1], vec![1.0; 4]);
441    }
442}