Skip to main content

rustmotion_core/engine/renderer/
audio_analysis.rs

1use dashmap::DashMap;
2use std::sync::{Arc, OnceLock};
3
4/// Per-track audio analysis: amplitude (RMS) and frequency bands per video frame.
5#[derive(Debug, Clone)]
6pub struct AudioAnalysis {
7    /// The video frame rate this analysis was computed at.
8    pub frame_rate: u32,
9    /// RMS amplitude per frame, normalized 0..1 over the whole track.
10    pub amplitude: Vec<f32>,
11    /// 16 log-spaced frequency bands (20 Hz–16 kHz) per frame, normalized 0..1.
12    pub bands: Vec<[f32; 16]>,
13    /// Where the track sits on the scenario timeline (`AudioTrack::start`).
14    ///
15    /// The analysis indexes the *file* from its own sample 0, while every
16    /// consumer asks in *scenario* time. Without this the two only line up when
17    /// `start == 0`: a track placed at 73 s played from its beginning while the
18    /// waveform drew the file's content at 73 s. Held here rather than at the
19    /// call sites because the cache is keyed by path and the painters never see
20    /// the `AudioTrack`.
21    pub start: f64,
22    /// `AudioTrack::end`, past which the track is cut and the visualisation
23    /// must go flat rather than keep drawing an envelope nobody hears.
24    pub end: Option<f64>,
25}
26
27impl AudioAnalysis {
28    /// Scenario time → offset into the file, or `None` when the track is not
29    /// playing at that moment. Every lookup below goes through this, so the
30    /// placement is applied in exactly one place.
31    fn track_time(&self, time: f64) -> Option<f64> {
32        if time < self.start {
33            return None;
34        }
35        if let Some(end) = self.end {
36            if time >= end {
37                return None;
38            }
39        }
40        Some(time - self.start)
41    }
42
43    /// Get the amplitude at a given time (seconds), clamped to valid range.
44    pub fn amplitude_at(&self, time: f64) -> f32 {
45        let Some(time) = self.track_time(time) else {
46            return 0.0;
47        };
48        let idx = (time * self.frame_rate as f64) as usize;
49        self.amplitude
50            .get(idx.min(self.amplitude.len().saturating_sub(1)))
51            .copied()
52            .unwrap_or(0.0)
53    }
54
55    /// Get a specific band [0..16) value at a given time.
56    pub fn band_at(&self, time: f64, band: u8) -> f32 {
57        let Some(time) = self.track_time(time) else {
58            return 0.0;
59        };
60        let idx = (time * self.frame_rate as f64) as usize;
61        let idx = idx.min(self.bands.len().saturating_sub(1));
62        self.bands
63            .get(idx)
64            .map(|b| b[band.min(15) as usize])
65            .unwrap_or(0.0)
66    }
67
68    /// Smoothed amplitude at time: average over the past `smoothing_frames` frames (inclusive of current).
69    pub fn amplitude_smoothed(&self, time: f64, smoothing_frames: u32) -> f32 {
70        if smoothing_frames == 0 || self.amplitude.is_empty() {
71            return self.amplitude_at(time);
72        }
73        let Some(time) = self.track_time(time) else {
74            return 0.0;
75        };
76        let end_idx = (time * self.frame_rate as f64) as usize;
77        let end_idx = end_idx.min(self.amplitude.len().saturating_sub(1));
78        let start_idx = end_idx.saturating_sub(smoothing_frames as usize);
79        let window = &self.amplitude[start_idx..=end_idx];
80        if window.is_empty() {
81            return 0.0;
82        }
83        window.iter().sum::<f32>() / window.len() as f32
84    }
85
86    /// Smoothed band value at time over `smoothing_frames` frames.
87    pub fn band_smoothed(&self, time: f64, band: u8, smoothing_frames: u32) -> f32 {
88        if smoothing_frames == 0 || self.bands.is_empty() {
89            return self.band_at(time, band);
90        }
91        let Some(time) = self.track_time(time) else {
92            return 0.0;
93        };
94        let end_idx = (time * self.frame_rate as f64) as usize;
95        let end_idx = end_idx.min(self.bands.len().saturating_sub(1));
96        let start_idx = end_idx.saturating_sub(smoothing_frames as usize);
97        let window = &self.bands[start_idx..=end_idx];
98        if window.is_empty() {
99            return 0.0;
100        }
101        window.iter().map(|b| b[band.min(15) as usize]).sum::<f32>() / window.len() as f32
102    }
103}
104
105type AudioCacheMap = Arc<DashMap<String, Arc<AudioAnalysis>>>;
106
107static AUDIO_ANALYSIS_CACHE: OnceLock<AudioCacheMap> = OnceLock::new();
108
109/// Global audio analysis cache: keyed by the audio track's `src` path.
110pub fn audio_analysis_cache() -> &'static AudioCacheMap {
111    AUDIO_ANALYSIS_CACHE.get_or_init(|| Arc::new(DashMap::new()))
112}