Skip to main content

media_pp/elements/source/test/
audio.rs

1use std::{
2    f64::consts::TAU,
3    sync::Arc,
4    thread,
5    time::{Duration, Instant},
6};
7
8use crate::pp_log::{PpLog, pp_info};
9use ffmpeg_next as ffmpeg;
10use thiserror::Error as ThisError;
11
12use crate::{
13    buffer::MediaBuffer,
14    bus::{Bus, BusEvent},
15    control::{ControlReceiver, drain_control},
16    element::{Element, ElementType, Source, SourceElement, element_pp_log},
17    error::Result,
18    pad::SrcPad,
19    schedule::ActiveTimeline,
20};
21
22/// How often [`TestAudioSource::run`] wakes up to top up however many
23/// samples wall-clock time now owes — same role/value as
24/// [`crate::elements::WasapiCaptureSource`]'s own `POLL_INTERVAL`/
25/// [`crate::elements::AudioMixer`]'s `TICK_INTERVAL`.
26const TICK_INTERVAL: Duration = Duration::from_millis(20);
27
28/// Errors specific to `TestAudioSource`. Converts into the crate-wide
29/// `Error` via `?` (see [`crate::error::Error`]).
30#[derive(Debug, ThisError)]
31pub enum TestAudioSourceError {
32    /// Seeking was requested on an unbounded generated stream.
33    #[error("TestAudioSource doesn't support seeking a generated stream")]
34    SeekUnsupported,
35}
36
37/// Construction-time options for [`TestAudioSource::new`].
38#[derive(Debug, Clone, Copy)]
39pub struct TestAudioOptions {
40    /// Sample rate of the generated audio, in hertz.
41    pub sample_rate: u32,
42    /// Channel count of the generated audio.
43    pub channels: u16,
44    /// The generated sine tone's frequency, in Hz. `440.0` (concert pitch
45    /// A) by default — audible, easy to recognize on a scope or by ear;
46    /// nothing else is special about the exact value.
47    pub frequency: f64,
48}
49
50impl Default for TestAudioOptions {
51    fn default() -> Self {
52        Self {
53            sample_rate: 48000,
54            channels: 2,
55            frequency: 440.0,
56        }
57    }
58}
59
60/// Generates a synthetic sine-wave tone — GStreamer's `audiotestsrc`
61/// equivalent. No real capture device involved: [`TestAudioSource::run`]
62/// fabricates however many samples wall-clock time now owes on a
63/// drift-free absolute schedule (`expected = elapsed * sample_rate`,
64/// `needed = expected - samples_emitted` — the same shape
65/// `WasapiCaptureSource::fill_silence_gap`/`AudioMixer::mix_tick` both
66/// use, not a fixed
67/// per-tick sample count, which would drift the same way a fixed-duration
68/// `thread::sleep`-only schedule would), stamps it with an increasing
69/// `pts` (one sample per tick of [`TestAudioSource::time_base`]'s units),
70/// and pushes it straight downstream — useful for exercising
71/// `AudioMixer`/an encoder/a muxer without a real microphone.
72///
73/// Always emits `Sample::F32(Packed)` — the same fixed internal format
74/// `AudioMixer` mixes in, so this can feed a `MixerHandle` input directly
75/// with nothing to resample (though `MixerInputSink` resamples regardless
76/// if fed something else instead, so this isn't load-bearing).
77///
78/// Runs until `Stop` — never reaches `Eos` on its own, same as every other
79/// live source in this crate (no sample-count limit is exposed,
80/// deliberately, mirroring a live capture source more than a file).
81pub struct TestAudioSource {
82    pp_log: PpLog,
83    name: Arc<str>,
84    pad: SrcPad,
85    sample_rate: u32,
86    channels: u16,
87    format: ffmpeg::format::Sample,
88    channel_layout: ffmpeg::ChannelLayout,
89    frequency: f64,
90    /// Cumulative sample count across every emitted frame — this
91    /// element's `pts` unit (see [`TestAudioSource::time_base`]) *and* the
92    /// sine wave's own running phase ([`TestAudioSource::generate_frame`]
93    /// divides this by `sample_rate` for `t`), so the waveform stays
94    /// phase-continuous across frame boundaries instead of restarting
95    /// from zero every tick.
96    samples_emitted: i64,
97}
98
99// SAFETY: see `AudioMixer`'s own `unsafe impl Send` docs — same
100// reasoning, `channel_layout` here is always `ChannelLayout::default`'s
101// plain native layout.
102unsafe impl Send for TestAudioSource {}
103
104impl TestAudioSource {
105    /// Creates an unbounded synthetic sine-wave source with the requested output definition.
106    pub fn new(name: impl Into<String>, options: TestAudioOptions) -> Self {
107        let name: Arc<str> = name.into().into();
108        let pp_log = element_pp_log(ElementType::TestAudioSource, &name, None);
109        pp_info!(
110            pp_log: &pp_log,
111            "created: {}Hz, {} channel(s), {}Hz tone",
112            options.sample_rate,
113            options.channels,
114            options.frequency
115        );
116        let pad = SrcPad::new(format!("{name}_src"));
117        Self {
118            name,
119            pp_log,
120            pad,
121            sample_rate: options.sample_rate,
122            channels: options.channels,
123            format: ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
124            channel_layout: ffmpeg::ChannelLayout::default(options.channels as i32),
125            frequency: options.frequency,
126            samples_emitted: 0,
127        }
128    }
129
130    /// The unit each emitted frame's `pts` is expressed in.
131    pub fn time_base(&self) -> ffmpeg::Rational {
132        ffmpeg::Rational::new(1, self.sample_rate as i32)
133    }
134
135    /// Fabricates the next `needed`-sample frame: the same sine tone on
136    /// every channel, phase-continuous with whatever's already been
137    /// emitted (see [`TestAudioSource::samples_emitted`]'s own docs).
138    fn generate_frame(&mut self, needed: usize) -> ffmpeg::frame::Audio {
139        let channels = self.channels as usize;
140        let mut interleaved = vec![0f32; needed * channels];
141        for (index, chunk) in interleaved.chunks_mut(channels).enumerate() {
142            let t = (self.samples_emitted + index as i64) as f64 / self.sample_rate as f64;
143            let sample = (t * self.frequency * TAU).sin() as f32;
144            chunk.fill(sample);
145        }
146
147        let mut frame = ffmpeg::frame::Audio::new(self.format, needed, self.channel_layout);
148        frame.set_rate(self.sample_rate);
149        // SAFETY: viewing an `f32` slice as bytes, which is always aligned and
150        // exactly `size_of_val` long. What the destination can take is the separate
151        // bound the comment below describes.
152        let bytes = unsafe {
153            std::slice::from_raw_parts(
154                interleaved.as_ptr() as *const u8,
155                std::mem::size_of_val(&*interleaved),
156            )
157        };
158        // Same tight-length write `AudioMixer::mix_tick`/
159        // `WasapiCaptureSource::build_frame` both use — `data_mut(0)`'s own
160        // length is FFmpeg's own padded linesize, not necessarily exactly
161        // `bytes.len()`.
162        frame.data_mut(0)[..bytes.len()].copy_from_slice(bytes);
163        frame.set_pts(Some(self.samples_emitted));
164        self.samples_emitted += needed as i64;
165        frame
166    }
167}
168
169impl Element for TestAudioSource {
170    fn name(&self) -> Arc<str> {
171        self.name.clone()
172    }
173
174    fn element_type(&self) -> ElementType {
175        ElementType::TestAudioSource
176    }
177
178    fn pp_log(&self) -> &crate::pp_log::PpLog {
179        &self.pp_log
180    }
181
182    fn pp_log_mut(&mut self) -> &mut crate::pp_log::PpLog {
183        &mut self.pp_log
184    }
185}
186
187impl Source for TestAudioSource {
188    fn src_pads(&mut self) -> &mut [SrcPad] {
189        std::slice::from_mut(&mut self.pad)
190    }
191}
192
193impl SourceElement for TestAudioSource {
194    fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
195        pp_info!(self, "started");
196        let mut timeline = ActiveTimeline::new(Instant::now());
197        loop {
198            let outcome = drain_control(control, self, bus)?;
199            if outcome.stopped {
200                pp_info!(self, "stopped");
201                return Ok(());
202            }
203            timeline.account_pause(outcome.paused_for);
204            thread::sleep(TICK_INTERVAL);
205
206            let expected =
207                (timeline.elapsed(Instant::now()).as_secs_f64() * self.sample_rate as f64) as i64;
208            let needed = (expected - self.samples_emitted).max(0) as usize;
209            if needed == 0 {
210                continue;
211            }
212            let frame = self.generate_frame(needed);
213            // A downstream failure drops just this one frame — same
214            // "report, don't die" contract every other source in this
215            // crate gives its own push.
216            if let Err(error) = self.pad.push(MediaBuffer::Audio(Arc::new(frame))) {
217                bus.post(
218                    &self.pp_log,
219                    BusEvent::Error {
220                        element_type: ElementType::TestAudioSource,
221                        name: self.name.clone(),
222                        error,
223                    },
224                );
225            }
226        }
227    }
228
229    fn seek(&mut self, _target: Duration) -> Result<Duration> {
230        Err(TestAudioSourceError::SeekUnsupported.into())
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use std::sync::Mutex;
237
238    use crate::pp_log::PpLog;
239
240    use super::*;
241    use crate::{control::ControlMsg, element::Sink, pipeline::Pipeline};
242
243    /// Captures every frame's `(format, rate, channels, pts, first_sample)`
244    /// it sees, in order.
245    struct RecordingSink {
246        pp_log: PpLog,
247        #[allow(clippy::type_complexity)]
248        seen: Arc<Mutex<Vec<(ffmpeg::format::Sample, u32, u16, Option<i64>, f32)>>>,
249    }
250
251    impl Element for RecordingSink {
252        fn name(&self) -> Arc<str> {
253            "recorder".into()
254        }
255        fn element_type(&self) -> ElementType {
256            ElementType::Other
257        }
258        fn pp_log(&self) -> &PpLog {
259            &self.pp_log
260        }
261        fn pp_log_mut(&mut self) -> &mut PpLog {
262            &mut self.pp_log
263        }
264    }
265
266    impl Sink for RecordingSink {
267        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
268            if let MediaBuffer::Audio(frame) = buf
269                && frame.samples() > 0
270            {
271                self.seen.lock().unwrap().push((
272                    frame.format(),
273                    frame.rate(),
274                    frame.channel_layout().channels() as u16,
275                    frame.pts(),
276                    frame.plane::<f32>(0)[0],
277                ));
278            }
279            Ok(())
280        }
281        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
282            Ok(())
283        }
284    }
285
286    #[test]
287    fn generates_f32_frames_with_increasing_pts_and_a_bounded_tone() {
288        let seen = Arc::new(Mutex::new(Vec::new()));
289        let sink = RecordingSink {
290            seen: seen.clone(),
291            pp_log: element_pp_log(ElementType::Other, "recorder", None),
292        };
293        let source = TestAudioSource::new(
294            "test-audio",
295            TestAudioOptions {
296                sample_rate: 48000,
297                channels: 2,
298                frequency: 440.0,
299            },
300        );
301
302        let pipeline = Pipeline::new("test", source, |source, ctx| {
303            let branch = ctx.branch().to(Box::new(sink))?;
304            ctx.attach(source, 0, branch)?;
305            Ok(())
306        })
307        .expect("test pipeline wiring must succeed");
308
309        pipeline.run().unwrap();
310        // Long enough to observe several ticks at the 20ms `TICK_INTERVAL`.
311        std::thread::sleep(Duration::from_millis(200));
312        pipeline.stop();
313        pipeline.bus().log_events();
314
315        let frames = seen.lock().unwrap();
316        assert!(!frames.is_empty(), "expected at least one generated frame");
317        for &(format, rate, channels, _, sample) in frames.iter() {
318            assert_eq!(
319                format,
320                ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed)
321            );
322            assert_eq!((rate, channels), (48000, 2));
323            assert!(
324                (-1.0..=1.0).contains(&sample),
325                "expected a bounded sine sample, got {sample}"
326            );
327        }
328        for window in frames.windows(2) {
329            assert!(
330                window[1].3 > window[0].3,
331                "expected pts to strictly increase frame over frame, got {:?} then {:?}",
332                window[0].3,
333                window[1].3
334            );
335        }
336    }
337
338    #[test]
339    fn seek_is_explicitly_unsupported() {
340        let mut source = TestAudioSource::new("test-audio", TestAudioOptions::default());
341        assert!(source.seek(Duration::from_secs(1)).is_err());
342    }
343
344    /// Regression test for the pause/resume timing bug: `start.elapsed()`
345    /// keeps advancing while [`Pipeline::pause`] blocks this source's own
346    /// loop inside `drain_control`. Without subtracting the accumulated
347    /// `ControlOutcome::paused_for` back out, `Resume` would find the
348    /// whole pause suddenly counted as owed samples and emit one wildly
349    /// oversized frame to cover it, instead of resuming its steady
350    /// per-tick sample count — each frame's `pts` is a running sample
351    /// count, so a healthy run never has two consecutive frames whose
352    /// `pts` gap is anywhere near a whole pause's worth of samples.
353    #[test]
354    fn resuming_after_a_pause_does_not_dump_a_burst_of_samples() {
355        let seen = Arc::new(Mutex::new(Vec::new()));
356        let sink = RecordingSink {
357            seen: seen.clone(),
358            pp_log: element_pp_log(ElementType::Other, "recorder", None),
359        };
360        let source = TestAudioSource::new(
361            "test-audio",
362            TestAudioOptions {
363                sample_rate: 48000,
364                channels: 2,
365                frequency: 440.0,
366            },
367        );
368
369        let pipeline = Pipeline::new("pause-resume-test", source, |source, ctx| {
370            let branch = ctx.branch().to(Box::new(sink))?;
371            ctx.attach(source, 0, branch)?;
372            Ok(())
373        })
374        .expect("test pipeline wiring must succeed");
375
376        pipeline.run().unwrap();
377        thread::sleep(Duration::from_millis(60));
378        pipeline.pause();
379        thread::sleep(Duration::from_millis(400));
380        pipeline.resume();
381        thread::sleep(Duration::from_millis(100));
382        pipeline.stop();
383        pipeline.bus().log_events();
384
385        let frames = seen.lock().unwrap();
386        let pts: Vec<i64> = frames.iter().filter_map(|&(_, _, _, pts, _)| pts).collect();
387        assert!(
388            pts.len() >= 2,
389            "expected multiple frames spanning the pause/resume, got {}",
390            pts.len()
391        );
392        for window in pts.windows(2) {
393            let gap = window[1] - window[0];
394            // A healthy tick's worth of samples at 48kHz/20ms is ~960; a
395            // 400ms pause treated as owed catch-up would show up as a
396            // ~19200-sample gap. 12000 (250ms) sits comfortably between
397            // the two.
398            assert!(
399                gap < 12_000,
400                "expected steady per-tick sample counts across resume, not a single burst \
401                 frame covering the whole pause: consecutive pts gap was {gap} samples \
402                 ({:.0}ms) — full pts sequence: {pts:?}",
403                gap as f64 / 48.0
404            );
405        }
406    }
407}