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