Skip to main content

media/
mock_audio.rs

1//! Deterministic mock audio sources (M-MEDIA.4 / AUT-100).
2//!
3//! Enables TDD for waveform + histogram rendering without requiring a
4//! microphone or GStreamer. Three shapes — sine wave, silence, step
5//! pulse — emit timestamped [`AudioChunk`]s with byte-exact reproducible
6//! samples. Every M-MEDIA test that wants "this is what audio looks
7//! like" without a live device uses one of these.
8//!
9//! # Sources
10//!
11//! - [`SineWaveSource`] — `sin(2π·f·t)`. Stable amplitude + frequency
12//!   are the histogram's "this RMS should be √2/2" reference.
13//! - [`SilenceSource`] — all zeros. The histogram's "this should
14//!   quantize to zero bars" reference.
15//! - [`StepPulseSource`] — a single 1.0 spike at a configurable frame
16//!   index, otherwise zero. The histogram's "this bucket should have a
17//!   detectable peak" reference.
18//!
19//! Each source is an iterator-like type with `next_chunk(frames)`. It
20//! advances an internal frame counter on each call; timestamps come
21//! from `MediaTime::from_sample`.
22
23use crate::audio::{AudioChunk, AudioFormat};
24use crate::clock::MediaTime;
25
26/// Sine-wave source. Emits `amplitude · sin(2π · frequency · t)` on
27/// every channel.
28#[derive(Debug, Clone)]
29pub struct SineWaveSource {
30    format: AudioFormat,
31    /// Hz.
32    frequency: f32,
33    /// 0..=1 (clipped at f32 bounds when written).
34    amplitude: f32,
35    /// Frames emitted so far.
36    next_frame: u64,
37}
38
39impl SineWaveSource {
40    /// Construct a sine source at the given format / frequency /
41    /// amplitude. `amplitude` is clamped to `[0, 1]`.
42    #[must_use]
43    pub fn new(format: AudioFormat, frequency_hz: f32, amplitude: f32) -> Self {
44        Self {
45            format,
46            frequency: frequency_hz.max(0.0),
47            amplitude: amplitude.clamp(0.0, 1.0),
48            next_frame: 0,
49        }
50    }
51
52    /// Format of the emitted chunks.
53    #[must_use]
54    pub fn format(&self) -> AudioFormat {
55        self.format
56    }
57
58    /// Cumulative number of frames emitted across `next_chunk` calls.
59    #[must_use]
60    pub fn frames_emitted(&self) -> u64 {
61        self.next_frame
62    }
63
64    /// Emit the next `frames` of audio. Always returns a chunk of
65    /// exactly `frames × channels` samples (or fewer if the requested
66    /// count is zero).
67    pub fn next_chunk(&mut self, frames: u64) -> AudioChunk {
68        let pts = MediaTime::from_sample(self.next_frame, self.format.sample_rate);
69        let total_samples = usize::try_from(frames).expect("frames fits in usize")
70            * usize::from(self.format.channels);
71        let mut samples = Vec::with_capacity(total_samples);
72        for f in 0..frames {
73            let frame_idx = self.next_frame + f;
74            let t = frame_idx_to_seconds(frame_idx, self.format.sample_rate);
75            let v = self.amplitude * (std::f32::consts::TAU * self.frequency * t).sin();
76            for _ in 0..self.format.channels {
77                samples.push(v);
78            }
79        }
80        self.next_frame = self.next_frame.saturating_add(frames);
81        AudioChunk::new(self.format, samples, pts).expect("sine source produces valid chunk")
82    }
83}
84
85/// Silence source — emits all-zero samples.
86#[derive(Debug, Clone)]
87pub struct SilenceSource {
88    format: AudioFormat,
89    next_frame: u64,
90}
91
92impl SilenceSource {
93    /// Construct.
94    #[must_use]
95    pub fn new(format: AudioFormat) -> Self {
96        Self {
97            format,
98            next_frame: 0,
99        }
100    }
101
102    /// Emit `frames` of silence.
103    pub fn next_chunk(&mut self, frames: u64) -> AudioChunk {
104        let pts = MediaTime::from_sample(self.next_frame, self.format.sample_rate);
105        let total_samples = usize::try_from(frames).expect("frames fits in usize")
106            * usize::from(self.format.channels);
107        let samples = vec![0.0_f32; total_samples];
108        self.next_frame = self.next_frame.saturating_add(frames);
109        AudioChunk::new(self.format, samples, pts).expect("silence source produces valid chunk")
110    }
111}
112
113/// Step-pulse source. Emits a single-frame `1.0` spike at
114/// `spike_frame_index`; every other frame is `0.0`. Useful for
115/// histogram-peak assertions that need a known location.
116#[derive(Debug, Clone)]
117pub struct StepPulseSource {
118    format: AudioFormat,
119    spike_frame_index: u64,
120    next_frame: u64,
121}
122
123impl StepPulseSource {
124    /// Construct with the spike at `spike_frame_index`.
125    #[must_use]
126    pub fn new(format: AudioFormat, spike_frame_index: u64) -> Self {
127        Self {
128            format,
129            spike_frame_index,
130            next_frame: 0,
131        }
132    }
133
134    /// Emit `frames` of step-pulse audio.
135    pub fn next_chunk(&mut self, frames: u64) -> AudioChunk {
136        let pts = MediaTime::from_sample(self.next_frame, self.format.sample_rate);
137        let total_samples = usize::try_from(frames).expect("frames fits in usize")
138            * usize::from(self.format.channels);
139        let mut samples = vec![0.0_f32; total_samples];
140        if self.spike_frame_index >= self.next_frame
141            && self.spike_frame_index < self.next_frame + frames
142        {
143            let offset_frames =
144                usize::try_from(self.spike_frame_index - self.next_frame).expect("fits in usize");
145            let base = offset_frames * usize::from(self.format.channels);
146            for c in 0..usize::from(self.format.channels) {
147                samples[base + c] = 1.0;
148            }
149        }
150        self.next_frame = self.next_frame.saturating_add(frames);
151        AudioChunk::new(self.format, samples, pts).expect("pulse source produces valid chunk")
152    }
153}
154
155fn frame_idx_to_seconds(frame: u64, sample_rate: u32) -> f32 {
156    // Compute in f64 then narrow; safe for any realistic
157    // (frame, rate) pair.
158    #[expect(
159        clippy::cast_precision_loss,
160        reason = "frame counts above 2^53 (≈ 195 days at 48 kHz) are not realistic"
161    )]
162    let frame_f = frame as f64;
163    let seconds = frame_f / f64::from(sample_rate);
164    #[expect(
165        clippy::cast_possible_truncation,
166        reason = "seconds in the realistic range fit f32"
167    )]
168    let s = seconds as f32;
169    s
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::audio::AudioFormat;
176
177    #[test]
178    fn sine_source_emits_expected_sample_count() {
179        let fmt = AudioFormat::mono_f32(48_000);
180        let mut src = SineWaveSource::new(fmt, 440.0, 0.5);
181        let chunk = src.next_chunk(480); // 10 ms at 48 kHz
182        assert_eq!(chunk.samples().len(), 480);
183        assert_eq!(chunk.frame_count(), 480);
184        assert_eq!(src.frames_emitted(), 480);
185    }
186
187    #[test]
188    fn sine_source_stereo_interleaves() {
189        let fmt = AudioFormat::stereo_f32(48_000);
190        let mut src = SineWaveSource::new(fmt, 440.0, 1.0);
191        let chunk = src.next_chunk(4);
192        // Stereo + 4 frames = 8 samples. L == R because the source
193        // emits the same value on every channel.
194        assert_eq!(chunk.samples().len(), 8);
195        for pair in chunk.samples().chunks_exact(2) {
196            assert!((pair[0] - pair[1]).abs() < f32::EPSILON);
197        }
198    }
199
200    #[test]
201    fn sine_source_peak_approximates_amplitude() {
202        // Over enough samples we should hit close to the requested
203        // amplitude. 480 samples covers ~4.4 cycles at 440 Hz / 48 kHz.
204        let fmt = AudioFormat::mono_f32(48_000);
205        let mut src = SineWaveSource::new(fmt, 440.0, 0.5);
206        let chunk = src.next_chunk(480);
207        let peak = chunk.peak();
208        assert!((peak - 0.5).abs() < 0.01, "expected peak ~ 0.5, got {peak}");
209    }
210
211    #[test]
212    fn sine_source_rms_approximates_amp_over_sqrt2() {
213        // For a sine wave, RMS = amplitude / sqrt(2) ≈ 0.7071·A.
214        let fmt = AudioFormat::mono_f32(48_000);
215        let mut src = SineWaveSource::new(fmt, 1_000.0, 1.0);
216        // Use 4800 samples (100 ms) so we average over plenty of cycles.
217        let chunk = src.next_chunk(4_800);
218        let rms = chunk.rms();
219        let expected = 1.0 / 2.0_f32.sqrt();
220        assert!(
221            (rms - expected).abs() < 0.01,
222            "expected rms ~ {expected}, got {rms}"
223        );
224    }
225
226    #[test]
227    fn sine_source_pts_advances_between_calls() {
228        let fmt = AudioFormat::mono_f32(48_000);
229        let mut src = SineWaveSource::new(fmt, 440.0, 0.5);
230        let c0 = src.next_chunk(48_000); // 1 s
231        let c1 = src.next_chunk(48_000); // 1 s
232        assert!((c0.pts().as_seconds() - 0.0).abs() < 1e-12);
233        assert!((c1.pts().as_seconds() - 1.0).abs() < 1e-12);
234    }
235
236    #[test]
237    fn silence_source_emits_zeros() {
238        let fmt = AudioFormat::mono_f32(48_000);
239        let mut src = SilenceSource::new(fmt);
240        let chunk = src.next_chunk(100);
241        assert_eq!(chunk.samples().len(), 100);
242        for s in chunk.samples() {
243            assert!(s.abs() < f32::EPSILON);
244        }
245        assert!(chunk.peak() < f32::EPSILON);
246        assert!(chunk.rms() < f32::EPSILON);
247    }
248
249    #[test]
250    fn pulse_source_emits_spike_at_expected_frame() {
251        let fmt = AudioFormat::mono_f32(48_000);
252        let mut src = StepPulseSource::new(fmt, 7);
253        let chunk = src.next_chunk(16);
254        // Sample 7 should be 1.0; everything else 0.0.
255        for (i, &s) in chunk.samples().iter().enumerate() {
256            if i == 7 {
257                assert!((s - 1.0).abs() < f32::EPSILON);
258            } else {
259                assert!(s.abs() < f32::EPSILON);
260            }
261        }
262        assert!((chunk.peak() - 1.0).abs() < f32::EPSILON);
263    }
264
265    #[test]
266    fn pulse_source_handles_spike_outside_window() {
267        // Spike at frame 100, but chunk only covers frames 0..16.
268        let fmt = AudioFormat::mono_f32(48_000);
269        let mut src = StepPulseSource::new(fmt, 100);
270        let chunk = src.next_chunk(16);
271        // Everything zero — spike is in a later chunk.
272        assert!(chunk.peak() < f32::EPSILON);
273        // Now advance past the spike.
274        let _ = src.next_chunk(80);
275        let chunk = src.next_chunk(16);
276        // Frame 100 within [96, 112) — first chunk had 0..16, second 16..96,
277        // this is third chunk: frames 96..112. Spike at index 100-96 = 4.
278        assert!((chunk.peak() - 1.0).abs() < f32::EPSILON);
279        assert!((chunk.samples()[4] - 1.0).abs() < f32::EPSILON);
280    }
281
282    #[test]
283    fn pulse_source_stereo_spike_fills_both_channels() {
284        let fmt = AudioFormat::stereo_f32(48_000);
285        let mut src = StepPulseSource::new(fmt, 2);
286        let chunk = src.next_chunk(4);
287        // 4 frames × 2 channels = 8 samples. Spike at frame 2 → samples 4 and 5.
288        for (i, &s) in chunk.samples().iter().enumerate() {
289            if i == 4 || i == 5 {
290                assert!((s - 1.0).abs() < f32::EPSILON);
291            } else {
292                assert!(s.abs() < f32::EPSILON);
293            }
294        }
295    }
296
297    #[test]
298    fn sources_have_no_gstreamer_or_device_dependency() {
299        // Smoke: constructing all three sources and emitting a chunk
300        // must succeed in any environment. The test itself is the
301        // assertion — no skip-guard needed.
302        let fmt = AudioFormat::mono_f32(48_000);
303        let _ = SineWaveSource::new(fmt, 440.0, 0.5).next_chunk(10);
304        let _ = SilenceSource::new(fmt).next_chunk(10);
305        let _ = StepPulseSource::new(fmt, 5).next_chunk(10);
306    }
307
308    #[test]
309    fn types_are_send_and_sync() {
310        fn assert_send_sync<T: Send + Sync>() {}
311        assert_send_sync::<SineWaveSource>();
312        assert_send_sync::<SilenceSource>();
313        assert_send_sync::<StepPulseSource>();
314    }
315}