1use crate::audio::AudioChunk;
27use crate::clock::{MediaDuration, MediaTime};
28
29#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct AudioBar {
33 pub start_time: MediaTime,
35 pub duration: MediaDuration,
37 pub peak: f32,
39 pub rms: f32,
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct AudioHistogram {
46 pub bucket_duration: MediaDuration,
48 pub bars: Vec<AudioBar>,
50}
51
52impl AudioHistogram {
53 #[must_use]
55 pub fn len(&self) -> usize {
56 self.bars.len()
57 }
58
59 #[must_use]
61 pub fn is_empty(&self) -> bool {
62 self.bars.is_empty()
63 }
64}
65
66#[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 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); let h = quantize(&chunk, MediaDuration::from_millis(50));
172 assert_eq!(h.len(), 20); 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); let h = quantize(&chunk, MediaDuration::from_millis(50));
193 assert_eq!(h.len(), 20);
194 let expected = 0.6 / 2.0_f32.sqrt();
195 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 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); let h = quantize(&chunk, MediaDuration::from_millis(20));
225 assert_eq!(h.len(), 50); }
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); let h = quantize(&chunk, MediaDuration::from_millis(10));
234 assert_eq!(h.len(), 100); }
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); 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 assert_eq!(h.bars[0].start_time, chunk.pts());
262 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 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}