1use crate::clock::{MediaDuration, MediaTime};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum SampleFormat {
40 F32,
42 I16,
44 U8,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub struct AudioFormat {
52 pub sample_rate: u32,
55 pub channels: u8,
58 pub sample_format: SampleFormat,
61}
62
63impl AudioFormat {
64 #[must_use]
67 pub const fn mono_f32(sample_rate: u32) -> Self {
68 Self {
69 sample_rate,
70 channels: 1,
71 sample_format: SampleFormat::F32,
72 }
73 }
74
75 #[must_use]
77 pub const fn stereo_f32(sample_rate: u32) -> Self {
78 Self {
79 sample_rate,
80 channels: 2,
81 sample_format: SampleFormat::F32,
82 }
83 }
84}
85
86#[derive(Debug, thiserror::Error)]
88pub enum AudioChunkError {
89 #[error(
92 "audio chunk sample length {len} is not a multiple of channel count {channels} \
93 (each frame must carry one sample per channel)"
94 )]
95 UnalignedSamples {
96 len: usize,
98 channels: u8,
100 },
101 #[error("audio format channel count must be > 0")]
103 ZeroChannels,
104 #[error("audio format sample rate must be > 0")]
106 ZeroSampleRate,
107}
108
109#[derive(Debug, Clone, PartialEq)]
115pub struct AudioChunk {
116 format: AudioFormat,
117 samples: Vec<f32>,
118 pts: MediaTime,
119 duration: MediaDuration,
120}
121
122impl AudioChunk {
123 pub fn new(
126 format: AudioFormat,
127 samples: Vec<f32>,
128 pts: MediaTime,
129 ) -> Result<Self, AudioChunkError> {
130 if format.channels == 0 {
131 return Err(AudioChunkError::ZeroChannels);
132 }
133 if format.sample_rate == 0 {
134 return Err(AudioChunkError::ZeroSampleRate);
135 }
136 if !samples.len().is_multiple_of(usize::from(format.channels)) {
137 return Err(AudioChunkError::UnalignedSamples {
138 len: samples.len(),
139 channels: format.channels,
140 });
141 }
142 let frames = (samples.len() / usize::from(format.channels)) as u64;
143 let duration = MediaTime::from_sample(frames, format.sample_rate) - MediaTime::ZERO;
144 Ok(Self {
145 format,
146 samples,
147 pts,
148 duration,
149 })
150 }
151
152 #[must_use]
154 pub fn format(&self) -> AudioFormat {
155 self.format
156 }
157
158 #[must_use]
160 pub fn samples(&self) -> &[f32] {
161 &self.samples
162 }
163
164 #[must_use]
166 pub fn frame_count(&self) -> usize {
167 self.samples.len() / usize::from(self.format.channels)
168 }
169
170 #[must_use]
173 pub fn pts(&self) -> MediaTime {
174 self.pts
175 }
176
177 #[must_use]
179 pub fn duration(&self) -> MediaDuration {
180 self.duration
181 }
182
183 #[must_use]
186 pub fn peak(&self) -> f32 {
187 self.samples
188 .iter()
189 .copied()
190 .fold(0.0_f32, |acc, x| acc.max(x.abs()))
191 }
192
193 #[must_use]
196 pub fn rms(&self) -> f32 {
197 if self.samples.is_empty() {
198 return 0.0;
199 }
200 let sum_sq: f64 = self.samples.iter().map(|&x| f64::from(x).powi(2)).sum();
201 #[expect(
202 clippy::cast_precision_loss,
203 reason = "sample count above 2^53 is not realistic for one chunk"
204 )]
205 let mean = sum_sq / (self.samples.len() as f64);
206 #[expect(
207 clippy::cast_possible_truncation,
208 reason = "RMS in [0, 1] easily fits f32"
209 )]
210 let rms = mean.sqrt() as f32;
211 rms
212 }
213}
214
215pub const METER_FLOOR_DBFS: f32 = -60.0;
220
221#[must_use]
245pub fn rms_to_meter_level(rms: f32) -> f32 {
246 if !rms.is_finite() || rms <= 0.0 {
247 return 0.0;
248 }
249 let db = 20.0 * rms.log10();
250 if db <= METER_FLOOR_DBFS {
251 return 0.0;
252 }
253 let level = (db - METER_FLOOR_DBFS) / -METER_FLOOR_DBFS;
254 level.clamp(0.0, 1.0)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn dummy_pts() -> MediaTime {
262 MediaTime::from_seconds(0.5)
263 }
264
265 #[test]
266 fn mono_48khz_one_second_round_trips() {
267 let fmt = AudioFormat::mono_f32(48_000);
268 let samples = vec![0.0_f32; 48_000]; let chunk = AudioChunk::new(fmt, samples, dummy_pts()).unwrap();
270 assert_eq!(chunk.frame_count(), 48_000);
271 assert!((chunk.duration().as_seconds() - 1.0).abs() < 1e-9);
272 assert_eq!(chunk.format().channels, 1);
273 assert!((chunk.pts().as_seconds() - 0.5).abs() < 1e-9);
274 }
275
276 #[test]
277 fn stereo_48khz_half_second_round_trips() {
278 let fmt = AudioFormat::stereo_f32(48_000);
279 let samples = vec![0.0_f32; 48_000];
281 let chunk = AudioChunk::new(fmt, samples, dummy_pts()).unwrap();
282 assert_eq!(chunk.frame_count(), 24_000);
283 assert!((chunk.duration().as_seconds() - 0.5).abs() < 1e-9);
284 assert_eq!(chunk.format().channels, 2);
285 }
286
287 #[test]
288 fn unaligned_samples_rejected_for_stereo() {
289 let fmt = AudioFormat::stereo_f32(48_000);
290 let samples = vec![0.0_f32; 481]; let err = AudioChunk::new(fmt, samples, dummy_pts()).unwrap_err();
292 assert!(
293 matches!(
294 err,
295 AudioChunkError::UnalignedSamples {
296 len: 481,
297 channels: 2
298 }
299 ),
300 "got {err:?}"
301 );
302 }
303
304 #[test]
305 fn zero_channels_rejected() {
306 let fmt = AudioFormat {
307 sample_rate: 48_000,
308 channels: 0,
309 sample_format: SampleFormat::F32,
310 };
311 let err = AudioChunk::new(fmt, vec![], dummy_pts()).unwrap_err();
312 assert!(matches!(err, AudioChunkError::ZeroChannels));
313 }
314
315 #[test]
316 fn zero_sample_rate_rejected() {
317 let fmt = AudioFormat {
318 sample_rate: 0,
319 channels: 1,
320 sample_format: SampleFormat::F32,
321 };
322 let err = AudioChunk::new(fmt, vec![0.0; 100], dummy_pts()).unwrap_err();
323 assert!(matches!(err, AudioChunkError::ZeroSampleRate));
324 }
325
326 #[test]
327 fn empty_buffer_is_valid_zero_duration() {
328 let fmt = AudioFormat::mono_f32(48_000);
329 let chunk = AudioChunk::new(fmt, vec![], dummy_pts()).unwrap();
330 assert_eq!(chunk.frame_count(), 0);
331 assert_eq!(chunk.duration(), MediaDuration::ZERO);
332 }
333
334 #[test]
335 fn peak_returns_max_abs_sample() {
336 let fmt = AudioFormat::mono_f32(48_000);
337 let samples = vec![0.1, -0.7, 0.3, -0.2];
338 let chunk = AudioChunk::new(fmt, samples, dummy_pts()).unwrap();
339 assert!((chunk.peak() - 0.7).abs() < 1e-6);
340 }
341
342 #[test]
343 fn rms_is_zero_for_silence() {
344 let fmt = AudioFormat::mono_f32(48_000);
345 let chunk = AudioChunk::new(fmt, vec![0.0; 100], dummy_pts()).unwrap();
346 assert!(chunk.rms().abs() < 1e-12);
347 }
348
349 #[test]
350 fn rms_for_unit_constant_signal_is_unit() {
351 let fmt = AudioFormat::mono_f32(48_000);
353 let chunk = AudioChunk::new(fmt, vec![1.0; 100], dummy_pts()).unwrap();
354 assert!((chunk.rms() - 1.0).abs() < 1e-6);
355 }
356
357 #[test]
358 fn presets_construct_correct_channels() {
359 assert_eq!(AudioFormat::mono_f32(48_000).channels, 1);
360 assert_eq!(AudioFormat::stereo_f32(48_000).channels, 2);
361 assert_eq!(
362 AudioFormat::mono_f32(48_000).sample_format,
363 SampleFormat::F32
364 );
365 }
366
367 #[test]
368 fn types_are_send_and_sync() {
369 fn assert_send_sync<T: Send + Sync>() {}
370 assert_send_sync::<AudioFormat>();
371 assert_send_sync::<AudioChunk>();
372 assert_send_sync::<AudioChunkError>();
373 }
374
375 fn approx_zero(x: f32) -> bool {
380 x.abs() < 1e-12
381 }
382
383 #[test]
384 fn meter_level_zero_rms_is_zero() {
385 assert!(approx_zero(rms_to_meter_level(0.0)));
386 }
387
388 #[test]
389 fn meter_level_full_scale_rms_is_one() {
390 assert!((rms_to_meter_level(1.0) - 1.0).abs() < 1e-6);
392 }
393
394 #[test]
395 fn meter_level_below_floor_clamps_to_zero() {
396 assert!(approx_zero(rms_to_meter_level(0.0001))); assert!(approx_zero(rms_to_meter_level(0.0009))); }
401
402 #[test]
403 fn meter_level_typical_speech_is_in_useful_mid_range() {
404 let level = rms_to_meter_level(0.03);
408 assert!(
409 (0.4..=0.6).contains(&level),
410 "expected speech RMS to map into 0.4..=0.6, got {level}"
411 );
412 }
413
414 #[test]
415 fn meter_level_monotonically_increases_with_rms() {
416 let inputs = [0.0, 0.001, 0.005, 0.01, 0.05, 0.1, 0.3, 0.7, 1.0];
417 let mut prev = -1.0;
418 for r in inputs {
419 let l = rms_to_meter_level(r);
420 assert!(l >= prev, "non-monotonic at rms={r}: {l} < {prev}");
421 prev = l;
422 }
423 }
424
425 #[test]
426 fn meter_level_rejects_nan_and_negative() {
427 assert!(approx_zero(rms_to_meter_level(f32::NAN)));
428 assert!(approx_zero(rms_to_meter_level(-0.5)));
429 assert!(approx_zero(rms_to_meter_level(f32::NEG_INFINITY)));
430 }
431
432 #[test]
433 fn meter_level_clamps_above_full_scale() {
434 assert!((rms_to_meter_level(2.0) - 1.0).abs() < 1e-6);
440 }
441}