Skip to main content

media/
audio.rs

1//! Audio sample + chunk data model (M-MEDIA.3 / AUT-99).
2//!
3//! Audio in this crate is **normalized `f32`** end-to-end: that's the
4//! shape every downstream visualization (audio-histogram quantization in
5//! M-MEDIA.8, waveform geometry in M-MEDIA.9) expects, and the shape
6//! GStreamer's `audioconvert ! audio/x-raw,format=F32LE` produces
7//! natively.
8//!
9//! # Types
10//!
11//! - [`AudioFormat`] — sample-rate + channel-count + sample-format
12//!   triple.
13//! - [`AudioChunk`] — a `Timestamped<Vec<f32>>` carrying its
14//!   [`AudioFormat`] + a derived [`MediaDuration`].
15//!
16//! Interleave order is **planar-per-frame**: `[L₀, R₀, L₁, R₁, …]` for
17//! stereo. Mono is just `[s₀, s₁, …]`. Matches the GStreamer raw audio
18//! convention; matches what cpal / coreaudio emit too, so future device
19//! capture won't need to re-layout buffers.
20//!
21//! # Quick start
22//!
23//! ```rust
24//! use media::audio::{AudioChunk, AudioFormat, SampleFormat};
25//! use media::clock::MediaTime;
26//!
27//! let fmt = AudioFormat::mono_f32(48_000);
28//! let samples = vec![0.0_f32; 48_000]; // 1.0 s of silence at 48 kHz mono.
29//! let chunk = AudioChunk::new(fmt, samples, MediaTime::ZERO).expect("valid");
30//! assert!((chunk.duration().as_seconds() - 1.0).abs() < 1e-9);
31//! ```
32
33use crate::clock::{MediaDuration, MediaTime};
34
35/// Internal sample format. Today every code path normalizes to
36/// `F32`; the enum exists so external GStreamer / cpal capture paths
37/// can declare their *input* layout before normalization.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum SampleFormat {
40    /// 32-bit float, range `[-1.0, +1.0]` after normalization.
41    F32,
42    /// 16-bit signed integer.
43    I16,
44    /// 8-bit unsigned integer.
45    U8,
46}
47
48/// Sample-rate + channels + sample-format triple. Describes the *layout*
49/// of an [`AudioChunk`]'s sample buffer.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub struct AudioFormat {
52    /// Samples per channel per second. 48 kHz is the recorder default;
53    /// 44.1 kHz is common for music sources.
54    pub sample_rate: u32,
55    /// Channel count. 1 = mono, 2 = stereo. Higher counts are valid
56    /// (5.1, 7.1) but unused for V1.
57    pub channels: u8,
58    /// On-disk / on-wire sample format. Normalized to `F32` inside
59    /// [`AudioChunk::samples`].
60    pub sample_format: SampleFormat,
61}
62
63impl AudioFormat {
64    /// Common preset: 48 kHz mono F32 — the recorder's default
65    /// microphone format.
66    #[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    /// Common preset: 48 kHz stereo F32.
76    #[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/// Validation failure for [`AudioChunk::new`].
87#[derive(Debug, thiserror::Error)]
88pub enum AudioChunkError {
89    /// Sample buffer length is not a multiple of the channel count.
90    /// Each *frame* must carry one sample per channel.
91    #[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        /// `samples.len()`.
97        len: usize,
98        /// `format.channels`.
99        channels: u8,
100    },
101    /// Channel count is zero — not a meaningful format.
102    #[error("audio format channel count must be > 0")]
103    ZeroChannels,
104    /// Sample rate is zero — not a meaningful format.
105    #[error("audio format sample rate must be > 0")]
106    ZeroSampleRate,
107}
108
109/// One timestamped chunk of normalized `f32` audio.
110///
111/// Sample buffer is interleaved (planar-per-frame): `[L₀, R₀, L₁, R₁,
112/// …]` for stereo. Mono is `[s₀, s₁, …]`. Duration is derived from
113/// `samples.len() / channels / sample_rate`.
114#[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    /// Validate + construct. Fails when channel count is zero, sample
124    /// rate is zero, or `samples.len()` isn't a multiple of `channels`.
125    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    /// Format of the carried samples.
153    #[must_use]
154    pub fn format(&self) -> AudioFormat {
155        self.format
156    }
157
158    /// Borrow the normalized `f32` sample buffer.
159    #[must_use]
160    pub fn samples(&self) -> &[f32] {
161        &self.samples
162    }
163
164    /// Number of *frames* (samples-per-channel) in the buffer.
165    #[must_use]
166    pub fn frame_count(&self) -> usize {
167        self.samples.len() / usize::from(self.format.channels)
168    }
169
170    /// Presentation timestamp — when this chunk's first sample
171    /// occurs on the media timeline.
172    #[must_use]
173    pub fn pts(&self) -> MediaTime {
174        self.pts
175    }
176
177    /// Derived duration of the chunk.
178    #[must_use]
179    pub fn duration(&self) -> MediaDuration {
180        self.duration
181    }
182
183    /// Convenience: peak |sample| over the buffer. Used by histogram
184    /// quantization (M-MEDIA.8) and capture-side regression checks.
185    #[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    /// Convenience: root-mean-square over the buffer. Used by
194    /// histogram quantization.
195    #[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
215/// Floor of the meter scale, in dBFS. Levels at or below this map to
216/// 0.0; full-scale (`rms = 1.0`, i.e. `0 dBFS`) maps to 1.0. -60 dBFS
217/// is a standard digital audio meter floor — anything below is barely
218/// audible.
219pub const METER_FLOOR_DBFS: f32 = -60.0;
220
221/// Convert a linear RMS value (`[0, 1]`) into a UI-meter level
222/// (`[0, 1]`) on a logarithmic dBFS scale, with `0 dBFS` mapping to
223/// `1.0` and [`METER_FLOOR_DBFS`] mapping to `0.0`.
224///
225/// Linear RMS is a useless meter input because human loudness
226/// perception (and microphone signal) is logarithmic — speech sits
227/// around `-30 dBFS` (RMS ≈ 0.03), while the linear scale would map
228/// the same speech to ~3 % of a 10-bar meter. The dBFS-mapped output
229/// puts conversational speech around 50 % of the bar, leaving
230/// headroom at both ends for whispers + shouts.
231///
232/// Indicative outputs (for the default `-60 dBFS` floor):
233///
234/// | Input RMS | dBFS    | Meter level |
235/// | --------- | ------- | ----------- |
236/// | 0.0       | -∞      | 0.00        |
237/// | 0.001     | -60     | 0.00        |
238/// | 0.003     | -50.5   | 0.16        |
239/// | 0.01      | -40     | 0.33        |
240/// | 0.03      | -30.5   | 0.49        |
241/// | 0.1       | -20     | 0.67        |
242/// | 0.3       | -10.5   | 0.83        |
243/// | 1.0       | 0       | 1.00        |
244#[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]; // 1.0 s
269        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        // 0.5 s at 48 kHz stereo = 24 000 frames × 2 channels = 48 000 samples.
280        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]; // odd → not multiple of 2
291        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        // For a constant 1.0 signal, RMS = 1.0.
352        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    // ---- rms_to_meter_level ----
376
377    /// `assert!`-friendly "near zero" check that satisfies clippy's
378    /// `float_cmp` lint (which rejects `assert_eq!(x, 0.0)`).
379    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        // 0 dBFS → top of meter.
391        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        // Anything quieter than -60 dBFS reads as 0 — far below
397        // audibility for a typical recording.
398        assert!(approx_zero(rms_to_meter_level(0.0001))); // -80 dBFS
399        assert!(approx_zero(rms_to_meter_level(0.0009))); // ~-61 dBFS
400    }
401
402    #[test]
403    fn meter_level_typical_speech_is_in_useful_mid_range() {
404        // Conversational speech RMS sits around 0.03 (-30 dBFS),
405        // which must land in the meaningful middle of the meter so
406        // the user sees it move.
407        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        // Sustained-overdrive case: RMS theoretically capped at 1.0
435        // for samples in [-1, 1], but EMA / mixer math could in
436        // pathological cases give a slightly larger value. Verify
437        // we clamp at 1.0 rather than running off the top of the
438        // meter.
439        assert!((rms_to_meter_level(2.0) - 1.0).abs() < 1e-6);
440    }
441}