Skip to main content

media/
histogram.rs

1//! Audio histogram quantization (M-MEDIA.8 / AUT-104).
2//!
3//! Turns an [`AudioChunk`] (or stream of chunks) into design-friendly
4//! rectangle bars for timeline / dope-sheet visualization. Each
5//! [`AudioBar`] carries `start_time` + `duration` (on the media
6//! timeline), `peak` (max `|sample|` in the bucket), and `rms`.
7//!
8//! # Bucket size
9//!
10//! Default range is **20–50 ms** — dope-sheet readability sweet spot
11//! (≈ 20–50 bars per second of audio). 10 ms is supported for
12//! fine-grained tests + scroll-through-zoom views.
13//!
14//! # Math
15//!
16//! - `peak = max_{samples in bucket}(|s|)`. Always in `[0, 1]`.
17//! - `rms  = sqrt(mean(s²))`. Same range. For a pure sine of
18//!   amplitude `A`, `rms ≈ A / √2 ≈ 0.7071·A`.
19//!
20//! Multi-channel chunks collapse to a single bar series — every
21//! sample in the interleaved buffer counts toward the same bucket.
22//! That matches dope-sheet rendering (one row per audio track, not
23//! per channel) and keeps the math + tests simple. M-MEDIA.9
24//! (geometry) handles mono vs stereo display modes.
25
26use crate::audio::AudioChunk;
27use crate::clock::{MediaDuration, MediaTime};
28
29/// One bar in an [`AudioHistogram`] — a quantized window of audio
30/// summarized by its peak + RMS.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct AudioBar {
33    /// When this bar's window starts on the media timeline.
34    pub start_time: MediaTime,
35    /// Window duration.
36    pub duration: MediaDuration,
37    /// Maximum `|sample|` over the window. `[0, 1]` for normalized audio.
38    pub peak: f32,
39    /// Root-mean-square over the window. `[0, 1]` for normalized audio.
40    pub rms: f32,
41}
42
43/// A list of [`AudioBar`]s with a uniform `bucket_duration`.
44#[derive(Debug, Clone, PartialEq)]
45pub struct AudioHistogram {
46    /// Width of each bucket on the media timeline.
47    pub bucket_duration: MediaDuration,
48    /// The bars, in increasing `start_time` order.
49    pub bars: Vec<AudioBar>,
50}
51
52impl AudioHistogram {
53    /// Convenience: number of bars.
54    #[must_use]
55    pub fn len(&self) -> usize {
56        self.bars.len()
57    }
58
59    /// Convenience: true when no bars are present.
60    #[must_use]
61    pub fn is_empty(&self) -> bool {
62        self.bars.is_empty()
63    }
64}
65
66/// Quantize a single [`AudioChunk`] into uniform-width bars.
67///
68/// The chunk's `pts` is the start of the first bar. Each bar covers
69/// `bucket_duration` of audio. The final bar may be shorter if the
70/// chunk doesn't divide evenly — it carries the actual remainder
71/// duration (not `bucket_duration`) so the timeline math stays exact.
72///
73/// # Panics
74///
75/// Panics if `bucket_duration` is zero or negative.
76#[must_use]
77pub fn quantize(chunk: &AudioChunk, bucket_duration: MediaDuration) -> AudioHistogram {
78    assert!(
79        bucket_duration.as_nanos() > 0,
80        "bucket_duration must be positive"
81    );
82
83    let fmt = chunk.format();
84    let channels = usize::from(fmt.channels);
85    let samples = chunk.samples();
86    let total_frames = chunk.frame_count();
87
88    // Frames per bucket = bucket_duration * sample_rate.
89    let frames_per_bucket = bucket_duration
90        .as_seconds()
91        .mul_add(f64::from(fmt.sample_rate), 0.0)
92        .round();
93    assert!(
94        frames_per_bucket >= 1.0,
95        "bucket_duration is shorter than one sample at sample_rate={}",
96        fmt.sample_rate
97    );
98    #[expect(
99        clippy::cast_possible_truncation,
100        clippy::cast_sign_loss,
101        reason = "frames_per_bucket guaranteed >= 1.0 above; ≤ realistic frame counts fit usize"
102    )]
103    let frames_per_bucket = frames_per_bucket as usize;
104
105    if total_frames == 0 {
106        return AudioHistogram {
107            bucket_duration,
108            bars: Vec::new(),
109        };
110    }
111
112    let bar_count = total_frames.div_ceil(frames_per_bucket);
113    let mut bars = Vec::with_capacity(bar_count);
114    let chunk_start = chunk.pts();
115
116    for bar_idx in 0..bar_count {
117        let frame_lo = bar_idx * frames_per_bucket;
118        let frame_hi = ((bar_idx + 1) * frames_per_bucket).min(total_frames);
119        let sample_lo = frame_lo * channels;
120        let sample_hi = frame_hi * channels;
121        let window = &samples[sample_lo..sample_hi];
122
123        let (peak, rms) = if window.is_empty() {
124            (0.0_f32, 0.0_f32)
125        } else {
126            let peak = window
127                .iter()
128                .copied()
129                .fold(0.0_f32, |acc, x| acc.max(x.abs()));
130            let sum_sq: f64 = window.iter().map(|&x| f64::from(x).powi(2)).sum();
131            #[expect(
132                clippy::cast_precision_loss,
133                reason = "window length above 2^53 is not realistic for one bar"
134            )]
135            let mean = sum_sq / (window.len() as f64);
136            #[expect(clippy::cast_possible_truncation, reason = "RMS in [0, 1] fits f32")]
137            let rms = mean.sqrt() as f32;
138            (peak, rms)
139        };
140
141        let start_offset =
142            MediaTime::from_sample(frame_lo as u64, fmt.sample_rate) - MediaTime::ZERO;
143        let actual_duration = MediaTime::from_sample(frame_hi as u64, fmt.sample_rate)
144            - MediaTime::from_sample(frame_lo as u64, fmt.sample_rate);
145
146        bars.push(AudioBar {
147            start_time: chunk_start + start_offset,
148            duration: actual_duration,
149            peak,
150            rms,
151        });
152    }
153
154    AudioHistogram {
155        bucket_duration,
156        bars,
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::audio::AudioFormat;
164    use crate::mock_audio::{SilenceSource, SineWaveSource, StepPulseSource};
165
166    #[test]
167    fn silence_chunk_produces_zero_amplitude_bars() {
168        let fmt = AudioFormat::mono_f32(48_000);
169        let mut src = SilenceSource::new(fmt);
170        let chunk = src.next_chunk(48_000); // 1 s
171        let h = quantize(&chunk, MediaDuration::from_millis(50));
172        assert_eq!(h.len(), 20); // 1 s / 50 ms = 20
173        for bar in &h.bars {
174            assert!(
175                bar.peak.abs() < f32::EPSILON,
176                "expected peak ~0, got {}",
177                bar.peak
178            );
179            assert!(
180                bar.rms.abs() < f32::EPSILON,
181                "expected rms ~0, got {}",
182                bar.rms
183            );
184        }
185    }
186
187    #[test]
188    fn sine_chunk_produces_stable_rms_near_amp_over_sqrt_2() {
189        let fmt = AudioFormat::mono_f32(48_000);
190        let mut src = SineWaveSource::new(fmt, 1_000.0, 0.6);
191        let chunk = src.next_chunk(48_000); // 1 s
192        let h = quantize(&chunk, MediaDuration::from_millis(50));
193        assert_eq!(h.len(), 20);
194        let expected = 0.6 / 2.0_f32.sqrt();
195        // Skip the first and last bars (boundary cycles may have less coverage).
196        for (i, bar) in h.bars.iter().enumerate().skip(1).take(18) {
197            assert!(
198                (bar.rms - expected).abs() < 0.05,
199                "bar {i} rms {} expected ~{expected}",
200                bar.rms,
201            );
202        }
203    }
204
205    #[test]
206    fn pulse_chunk_produces_one_bar_with_peak_one() {
207        let fmt = AudioFormat::mono_f32(48_000);
208        // Spike at frame 1200 → 25 ms in. With 50 ms buckets, that's
209        // bar index 0.
210        let mut src = StepPulseSource::new(fmt, 1_200);
211        let chunk = src.next_chunk(48_000);
212        let h = quantize(&chunk, MediaDuration::from_millis(50));
213        assert!((h.bars[0].peak - 1.0).abs() < f32::EPSILON);
214        for bar in &h.bars[1..] {
215            assert!(bar.peak.abs() < f32::EPSILON);
216        }
217    }
218
219    #[test]
220    fn bucket_count_matches_duration_at_20ms() {
221        let fmt = AudioFormat::mono_f32(48_000);
222        let mut src = SilenceSource::new(fmt);
223        let chunk = src.next_chunk(48_000); // 1 s
224        let h = quantize(&chunk, MediaDuration::from_millis(20));
225        assert_eq!(h.len(), 50); // 1 s / 20 ms = 50
226    }
227
228    #[test]
229    fn bucket_count_matches_duration_at_10ms() {
230        let fmt = AudioFormat::mono_f32(48_000);
231        let mut src = SilenceSource::new(fmt);
232        let chunk = src.next_chunk(48_000); // 1 s
233        let h = quantize(&chunk, MediaDuration::from_millis(10));
234        assert_eq!(h.len(), 100); // 1 s / 10 ms = 100
235    }
236
237    #[test]
238    fn bucket_count_matches_duration_at_50ms() {
239        let fmt = AudioFormat::mono_f32(48_000);
240        let mut src = SilenceSource::new(fmt);
241        let chunk = src.next_chunk(48_000); // 1 s
242        let h = quantize(&chunk, MediaDuration::from_millis(50));
243        assert_eq!(h.len(), 20);
244    }
245
246    #[test]
247    fn empty_chunk_produces_zero_bars() {
248        let fmt = AudioFormat::mono_f32(48_000);
249        let chunk = AudioChunk::new(fmt, vec![], MediaTime::ZERO).unwrap();
250        let h = quantize(&chunk, MediaDuration::from_millis(20));
251        assert!(h.is_empty());
252    }
253
254    #[test]
255    fn bar_timestamps_are_contiguous_and_start_at_chunk_pts() {
256        let fmt = AudioFormat::mono_f32(48_000);
257        let mut src = SineWaveSource::new(fmt, 440.0, 0.5);
258        let chunk = src.next_chunk(48_000);
259        let h = quantize(&chunk, MediaDuration::from_millis(50));
260        // First bar starts at the chunk's PTS (0 here).
261        assert_eq!(h.bars[0].start_time, chunk.pts());
262        // Successive bars are contiguous: bar[i+1].start = bar[i].start + bar[i].duration.
263        for i in 0..(h.bars.len() - 1) {
264            let next_expected = h.bars[i].start_time + h.bars[i].duration;
265            assert_eq!(
266                h.bars[i + 1].start_time,
267                next_expected,
268                "gap or overlap between bar {i} and {}",
269                i + 1
270            );
271        }
272    }
273
274    #[test]
275    fn stereo_chunk_collapses_channels_into_single_bar_series() {
276        let fmt = AudioFormat::stereo_f32(48_000);
277        let mut src = SineWaveSource::new(fmt, 440.0, 0.6);
278        let chunk = src.next_chunk(48_000);
279        let h = quantize(&chunk, MediaDuration::from_millis(50));
280        assert_eq!(h.len(), 20);
281        // RMS should still be ≈ 0.6 / √2 because L == R from the
282        // SineWaveSource — averaging doesn't change it.
283        let expected = 0.6 / 2.0_f32.sqrt();
284        for (i, bar) in h.bars.iter().enumerate().skip(1).take(18) {
285            assert!(
286                (bar.rms - expected).abs() < 0.05,
287                "stereo bar {i} rms {} expected ~{expected}",
288                bar.rms,
289            );
290        }
291    }
292
293    #[test]
294    fn types_are_send_and_sync() {
295        fn assert_send_sync<T: Send + Sync>() {}
296        assert_send_sync::<AudioBar>();
297        assert_send_sync::<AudioHistogram>();
298    }
299}