media_pp/elements/source/audio_mixer.rs
1use std::{
2 collections::{HashMap, VecDeque},
3 sync::{
4 Arc, Mutex, Weak,
5 atomic::{AtomicU64, Ordering},
6 },
7 thread,
8 time::{Duration, Instant},
9};
10
11use crate::pp_log::{PpLog, pp_error, pp_info};
12use ffmpeg_next as ffmpeg;
13use thiserror::Error as ThisError;
14
15use crate::{
16 buffer::MediaBuffer,
17 bus::{Bus, BusEvent},
18 control::{ControlMsg, ControlReceiver, drain_control},
19 element::{Element, ElementType, Sink, Source, SourceElement, element_pp_log},
20 error::Result,
21 pad::SrcPad,
22 schedule::ActiveTimeline,
23};
24
25/// How often [`AudioMixer::run`] mixes and emits a combined frame — same
26/// role as [`crate::elements::DxgiCaptureSource`]'s own `POLL_GRANULARITY`/
27/// `crate::elements::WasapiCaptureSource`'s `POLL_INTERVAL`: bounds `Stop`
28/// latency and sets the mixer's own output granularity.
29const TICK_INTERVAL: Duration = Duration::from_millis(20);
30
31/// Errors specific to `AudioMixer`. Converts into the crate-wide `Error`
32/// via `?` (see [`crate::error::Error`]).
33#[derive(Debug, ThisError)]
34pub enum AudioMixerError {
35 /// FFmpeg rejected resampler creation or audio conversion.
36 #[error("ffmpeg error: {0}")]
37 Ffmpeg(#[from] ffmpeg_next::Error),
38
39 /// Seeking was requested on a live mixer with no stored timeline.
40 #[error("AudioMixer doesn't support seeking a live mix")]
41 SeekUnsupported,
42
43 /// An input sink received a buffer other than decoded audio or end-of-stream.
44 #[error("AudioMixer inputs only accept Audio or Eos buffers, got {0}")]
45 UnsupportedBuffer(&'static str),
46}
47
48/// Construction-time options for [`AudioMixer::new`] — the mixer's fixed
49/// *output* format. Every input is resampled to match this on the way in
50/// (see `InputBuffer::push`); the mixer never adapts to whatever an
51/// input happens to produce.
52#[derive(Debug, Clone, Copy)]
53pub struct AudioMixerOptions {
54 /// Sample rate of every mixed output frame, in hertz.
55 pub sample_rate: u32,
56 /// Channel count of every mixed output frame.
57 pub channels: u16,
58}
59
60/// One input's own resampler and accumulated (already-resampled,
61/// interleaved `f32`) samples, waiting to be drained by the next
62/// [`AudioMixer::mix_tick`]. The resampler is built lazily from the first
63/// frame this input ever sees (its `format`/`channel_layout`/`rate`
64/// self-describe — no need for [`MixerHandle::add_source`] to be told
65/// this upfront).
66struct InputBuffer {
67 /// Identity of this particular registration. The name can be reused,
68 /// but an older sink must not be allowed to touch its replacement.
69 id: u64,
70 resampler: Option<ffmpeg::software::resampling::Context>,
71 samples: VecDeque<f32>,
72 /// Set once this input's `Eos` arrives — [`AudioMixer::mix_tick`]
73 /// drops the input entirely once it's both `eos` and fully drained,
74 /// same as a `Tee` branch dropping out once removed. Unlike a fixed
75 /// two-track muxer, `AudioMixer` has no fixed input count to wait on:
76 /// one input reaching `Eos` just means the mix continues without it.
77 eos: bool,
78}
79
80impl InputBuffer {
81 fn push(
82 &mut self,
83 frame: &ffmpeg::frame::Audio,
84 target_format: ffmpeg::format::Sample,
85 target_layout: ffmpeg::ChannelLayout,
86 target_rate: u32,
87 ) -> std::result::Result<(), AudioMixerError> {
88 let resampler = match &mut self.resampler {
89 Some(resampler) => resampler,
90 None => {
91 let resampler = ffmpeg::software::resampling::Context::get(
92 frame.format(),
93 frame.channel_layout(),
94 frame.rate(),
95 target_format,
96 target_layout,
97 target_rate,
98 )?;
99 self.resampler.insert(resampler)
100 }
101 };
102 let mut output = ffmpeg::frame::Audio::empty();
103 resampler.run(frame, &mut output)?;
104 // Raw bytes, not `plane::<f32>(0)`: `ffmpeg_next`'s `plane::<T>()`
105 // always returns exactly `output.samples()` elements of type `T`,
106 // which for **packed multi-channel** data (this mixer's own fixed
107 // `Sample::F32(Packed)` target — see `AudioMixer::new`) covers only
108 // the first `samples()` of the real `samples() * channels`
109 // interleaved scalars actually in the buffer, silently dropping
110 // every channel past the first once `target_layout` has more than
111 // one. Same fix, and the same root cause, as
112 // `crate::elements::SwAudioEncoder`'s own `absorb_resampled`
113 // (found while building that element — this call predates it).
114 let samples = output.samples();
115 let channels = target_layout.channels() as usize;
116 let bytes = &output.data(0)[..samples * channels * 4];
117 let interleaved =
118 // SAFETY: `bytes` is a prefix of an FFmpeg audio plane, which is aligned
119 // well past 4 and whose length here is an exact multiple of four bytes —
120 // `samples * channels * 4`.
121 unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const f32, bytes.len() / 4) };
122 self.samples.extend(interleaved.iter().copied());
123 Ok(())
124 }
125}
126
127/// Shared state between [`AudioMixer`] and every [`MixerHandle`]/
128/// [`MixerInputSink`] derived from it — just the input map, behind one
129/// lock (same granularity [`crate::elements::Tee`]'s own `TeeShared::pads`
130/// uses: one lock for the whole collection, not one per entry, since a mix
131/// tick already needs to visit every input together anyway).
132struct MixerShared {
133 inputs: Mutex<HashMap<Arc<str>, InputBuffer>>,
134 /// Issues a distinct identity for every `add_source` call, including
135 /// replacements registered under an existing name.
136 next_input_id: AtomicU64,
137}
138
139/// A cheaply-cloneable handle for adding or removing an [`AudioMixer`]'s
140/// input sources while the pipeline is running — the mirror image of
141/// [`crate::elements::TeeHandle`]: `Tee` lets you attach/detach *outputs*
142/// from another thread; this lets you attach/detach *inputs*. Keeps only a
143/// [`Weak`] reference for the same reason `TeeHandle` does: retaining a
144/// handle after the mixer's own pipeline finishes must not keep its
145/// internal state alive forever, and every operation becomes a harmless
146/// no-op once the mixer is gone.
147#[derive(Clone)]
148pub struct MixerHandle {
149 shared: Weak<MixerShared>,
150 sample_rate: u32,
151 format: ffmpeg::format::Sample,
152 channel_layout: ffmpeg::ChannelLayout,
153}
154
155impl MixerHandle {
156 /// Registers a new input under `name` and returns a [`Sink`] to use as
157 /// a detached branch terminal. Build and attach it inside that source's
158 /// own `Pipeline::new` wiring closure — a
159 /// *different* pipeline/thread than this mixer's own, which is exactly
160 /// the point). `None` once the mixer itself is gone. Calling this
161 /// again with a name already in use replaces that input outright
162 /// (whatever it had buffered is dropped) rather than erroring — same
163 /// "just do what was asked" spirit as `HashMap::insert`. A sink from
164 /// the previous registration then becomes inert: its data, `Eos`, and
165 /// `Stop` cannot affect the replacement sharing its name.
166 ///
167 /// The input endpoint appears in the upstream source pipeline's graph
168 /// when attached through [`crate::element::Context::attach`]. The graph
169 /// intentionally does not invent a cross-pipeline edge to the mixer.
170 pub fn add_source(&self, name: impl Into<String>) -> Option<Box<dyn Sink>> {
171 let shared = self.shared.upgrade()?;
172 let name: Arc<str> = name.into().into();
173 let id = shared.next_input_id.fetch_add(1, Ordering::Relaxed);
174 shared.inputs.lock().unwrap().insert(
175 name.clone(),
176 InputBuffer {
177 id,
178 resampler: None,
179 samples: VecDeque::new(),
180 eos: false,
181 },
182 );
183 Some(Box::new(MixerInputSink {
184 name: name.clone(),
185 id,
186 pp_log: element_pp_log(ElementType::AudioMixer, &name, None),
187 shared: self.shared.clone(),
188 target_format: self.format,
189 target_layout: self.channel_layout,
190 target_rate: self.sample_rate,
191 }))
192 }
193
194 /// Drops `name`'s input immediately, discarding whatever it had
195 /// buffered — a no-op if `name` isn't currently registered, or the
196 /// mixer is gone.
197 pub fn remove_source(&self, name: &str) {
198 if let Some(shared) = self.shared.upgrade() {
199 shared.inputs.lock().unwrap().remove(name);
200 }
201 }
202
203 /// Returns the number of inputs currently registered with the live mixer.
204 ///
205 /// Returns zero after the mixer has been dropped.
206 pub fn source_count(&self) -> usize {
207 self.shared
208 .upgrade()
209 .map(|shared| shared.inputs.lock().unwrap().len())
210 .unwrap_or(0)
211 }
212}
213
214/// One [`AudioMixer`] input, returned by [`MixerHandle::add_source`].
215/// Resamples every incoming frame to the mixer's fixed output format and
216/// appends it to this input's own buffer — the actual summing happens
217/// later, on [`AudioMixer::run`]'s own thread, not here. `consume` runs on
218/// whatever thread is driving the *upstream* source this got linked to
219/// (a different pipeline's own thread, in the normal case), so every
220/// access to the shared input map goes through `MixerShared`'s lock.
221pub struct MixerInputSink {
222 pp_log: PpLog,
223 name: Arc<str>,
224 /// Identity returned by the corresponding `add_source` call. Compared
225 /// with the map entry before every mutation so a stale sink cannot
226 /// write to or remove a same-name replacement.
227 id: u64,
228 shared: Weak<MixerShared>,
229 target_format: ffmpeg::format::Sample,
230 target_layout: ffmpeg::ChannelLayout,
231 target_rate: u32,
232}
233
234// SAFETY: `ffmpeg::ChannelLayout` wraps `AVChannelLayout`, which carries a
235// non-`Send` custom-layout pointer only for `AV_CHANNEL_ORDER_CUSTOM`
236// layouts. Every `ChannelLayout` here comes from `ChannelLayout::default`
237// (see `AudioMixer::new`) — a plain native layout, that pointer always
238// null — so there's nothing thread-unsafe actually being sent.
239unsafe impl Send for MixerInputSink {}
240
241impl Element for MixerInputSink {
242 fn name(&self) -> Arc<str> {
243 self.name.clone()
244 }
245
246 fn element_type(&self) -> ElementType {
247 ElementType::AudioMixer
248 }
249
250 fn pp_log(&self) -> &PpLog {
251 &self.pp_log
252 }
253
254 fn pp_log_mut(&mut self) -> &mut PpLog {
255 &mut self.pp_log
256 }
257}
258
259impl Sink for MixerInputSink {
260 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
261 let Some(shared) = self.shared.upgrade() else {
262 return Ok(()); // mixer's own pipeline already ended — nothing to feed
263 };
264 match buf {
265 MediaBuffer::Audio(frame) => {
266 let mut inputs = shared.inputs.lock().unwrap();
267 if let Some(input) = inputs.get_mut(&self.name)
268 && input.id == self.id
269 {
270 input.push(
271 &frame,
272 self.target_format,
273 self.target_layout,
274 self.target_rate,
275 )?;
276 }
277 // Absent means `remove_source` raced ahead of this frame;
278 // an ID mismatch means this name was replaced. Dropping
279 // the frame is correct in both cases.
280 }
281 MediaBuffer::Eos => {
282 let mut inputs = shared.inputs.lock().unwrap();
283 if let Some(input) = inputs.get_mut(&self.name)
284 && input.id == self.id
285 {
286 input.eos = true;
287 }
288 }
289 other => {
290 pp_error!(self, "unsupported buffer: expected Audio or Eos");
291 return Err(AudioMixerError::UnsupportedBuffer(other.kind()).into());
292 }
293 }
294 Ok(())
295 }
296
297 /// No downstream of its own to cascade to — this is a leaf input slot,
298 /// not a passthrough. `Stop` removes this input immediately, same as
299 /// [`MixerHandle::remove_source`]: `Stop` means abandon now, not drain
300 /// to a natural `Eos` (see `ControlMsg::Stop`'s own docs), and for a
301 /// live capture source — `WasapiCaptureSource`
302 /// included — `Stop` is the *only* shutdown signal that ever arrives;
303 /// it never reaches `Eos` on its own. Relying on `Eos` alone to clean
304 /// up (as an earlier version of this did) left a stale entry in
305 /// `shared.inputs` forever whenever a caller stopped its capture
306 /// pipeline normally instead of remembering to call
307 /// `MixerHandle::remove_source` by hand. `Pause`/`Resume`/`Seek` need
308 /// no handling here — this input has no thread or queue of its own to
309 /// freeze/resume, and a live capture source doesn't seek. Removal is
310 /// conditional on the registration ID: a late `Stop` from a replaced
311 /// sink must not remove the newer input using the same name.
312 fn control(&mut self, msg: ControlMsg) -> Result<()> {
313 if msg == ControlMsg::Stop
314 && let Some(shared) = self.shared.upgrade()
315 {
316 let mut inputs = shared.inputs.lock().unwrap();
317 if inputs
318 .get(&self.name)
319 .is_some_and(|input| input.id == self.id)
320 {
321 inputs.remove(&self.name);
322 }
323 }
324 Ok(())
325 }
326}
327
328/// Sums an arbitrary, dynamically-changing number of audio sources into
329/// one output stream — the structural mirror of [`crate::elements::Tee`]:
330/// `Tee` is one input fanned out to a dynamic set of outputs behind a
331/// lock; `AudioMixer` is a dynamic set of inputs (added/removed via
332/// [`MixerHandle`], from whatever thread each one's own source pipeline
333/// runs on) summed into one output. Unlike `Tee`, which is a passive
334/// [`Sink`] driven entirely by whatever calls `consume`, `AudioMixer` has
335/// to drive itself: it's a [`SourceElement`] with its own `run` thread,
336/// ticking every `TICK_INTERVAL` to sum however many samples each
337/// currently-attached input has ready — because mixing has to keep
338/// producing *something* on a steady clock even when some (or all) inputs
339/// have gone quiet, the same reason
340/// `WasapiCaptureSource` synthesizes silence for gaps
341/// rather than just emitting nothing.
342///
343/// Every input is resampled to this mixer's own fixed
344/// `sample_rate`/`channels` (always `Sample::F32(Packed)` internally —
345/// float headroom during summation, same reason real mixing consoles
346/// work in float even when everything else is integer PCM) — an input
347/// short on samples for a given tick contributes silence for the
348/// shortfall rather than blocking the whole mix. Samples are summed and
349/// **hard-clipped** to `[-1.0, 1.0]`, not averaged: two or three sources
350/// is the expected case, where clipping is rare, and averaging would
351/// quietly lower the whole mix's volume every time a source count
352/// changes — a caller who wants headroom can lower an individual input's
353/// gain before it ever reaches the mixer (not implemented — nothing needs
354/// it yet).
355///
356/// `pts` is a plain, always-continuous sample count (see
357/// [`AudioMixer::time_base`]), advancing in lockstep with wall-clock time
358/// regardless of which/how many inputs are actually contributing at any
359/// moment.
360///
361/// Runs until `Stop` — never reaches `Eos` on its own, same as every
362/// other live source in this crate; an individual input reaching `Eos` or
363/// being removed just drops out of future ticks, it doesn't end the mix.
364pub struct AudioMixer {
365 pp_log: PpLog,
366 name: Arc<str>,
367 shared: Arc<MixerShared>,
368 pad: SrcPad,
369 sample_rate: u32,
370 format: ffmpeg::format::Sample,
371 channel_layout: ffmpeg::ChannelLayout,
372 channels: u16,
373 /// Cumulative sample count across every emitted frame — see
374 /// [`AudioMixer::time_base`].
375 samples_emitted: i64,
376}
377
378// SAFETY: see `MixerInputSink`'s own `unsafe impl Send` docs — same
379// reasoning, `channel_layout` here is always `ChannelLayout::default`'s
380// plain native layout.
381unsafe impl Send for AudioMixer {}
382
383impl AudioMixer {
384 /// Starts with no inputs — add some via the returned [`MixerHandle`]
385 /// before (or any time after) wiring `AudioMixer` into a
386 /// [`crate::pipeline::Pipeline`] (`Pipeline::new` registers it as that
387 /// pipeline's own source automatically, same as any other
388 /// [`SourceElement`] — no [`crate::element::Context`] needed here,
389 /// unlike [`crate::elements::TeeBuilder::new`], since `AudioMixer` has no
390 /// chains of its own for a handle to build).
391 pub fn new(name: impl Into<String>, options: AudioMixerOptions) -> (Self, MixerHandle) {
392 let name: Arc<str> = name.into().into();
393 let pp_log = element_pp_log(ElementType::AudioMixer, &name, None);
394 pp_info!(
395 pp_log: &pp_log,
396 "created: {}Hz, {} channel(s)",
397 options.sample_rate,
398 options.channels
399 );
400 let format = ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed);
401 let channel_layout = ffmpeg::ChannelLayout::default(options.channels as i32);
402 let shared = Arc::new(MixerShared {
403 inputs: Mutex::new(HashMap::new()),
404 next_input_id: AtomicU64::new(0),
405 });
406 let pad = SrcPad::new(format!("{name}_src"));
407 (
408 Self {
409 name: name.clone(),
410 pp_log,
411 shared: shared.clone(),
412 pad,
413 sample_rate: options.sample_rate,
414 format,
415 channel_layout,
416 channels: options.channels,
417 samples_emitted: 0,
418 },
419 MixerHandle {
420 shared: Arc::downgrade(&shared),
421 sample_rate: options.sample_rate,
422 format,
423 channel_layout,
424 },
425 )
426 }
427
428 /// The unit each emitted frame's `pts` is expressed in.
429 pub fn time_base(&self) -> ffmpeg::Rational {
430 ffmpeg::Rational::new(1, self.sample_rate as i32)
431 }
432
433 /// Sums however many samples are needed to keep `samples_emitted` in
434 /// lockstep with `elapsed` (a no-op if nothing's owed yet — same
435 /// wall-clock-deficit shape as
436 /// [`crate::elements::WasapiCaptureSource::fill_silence_gap`], just
437 /// summing real contributions from every input instead of emitting
438 /// pure silence). `elapsed` already excludes time spent frozen inside
439 /// `Pause` (see [`crate::schedule::ActiveTimeline`]) so a `Pause`/
440 /// `Resume` pair doesn't get summed as a burst of owed samples the
441 /// moment playback resumes. Drops any input that's both `eos` and
442 /// fully drained — it contributed its last real samples on a previous
443 /// tick and has nothing left to give.
444 fn mix_tick(&mut self, elapsed: Duration, bus: &Bus) {
445 let channels = self.channels as usize;
446 let expected = (elapsed.as_secs_f64() * self.sample_rate as f64) as i64;
447 let needed = (expected - self.samples_emitted).max(0) as usize;
448 if needed == 0 {
449 return;
450 }
451 let mut mixed = vec![0f32; needed * channels];
452 {
453 let mut inputs = self.shared.inputs.lock().unwrap();
454 inputs.retain(|_, input| !(input.eos && input.samples.is_empty()));
455 for input in inputs.values_mut() {
456 let take = mixed.len().min(input.samples.len());
457 for (slot, sample) in mixed.iter_mut().zip(input.samples.iter()) {
458 *slot += *sample;
459 }
460 input.samples.drain(0..take);
461 }
462 }
463 for sample in &mut mixed {
464 *sample = sample.clamp(-1.0, 1.0);
465 }
466
467 let mut frame = ffmpeg::frame::Audio::new(self.format, needed, self.channel_layout);
468 frame.set_rate(self.sample_rate);
469 // SAFETY: viewing an `f32` slice as bytes, which is always aligned and
470 // exactly `size_of_val` long. The read is only as wide as `mixed` itself;
471 // what the *destination* can take is the separate bound the comment below
472 // describes.
473 let bytes = unsafe {
474 std::slice::from_raw_parts(mixed.as_ptr() as *const u8, std::mem::size_of_val(&*mixed))
475 };
476 // `frame.data_mut(0)`'s length is FFmpeg's own padded linesize,
477 // not necessarily `mixed.len() * 4` exactly — only ever write that
478 // tight amount (same bound `frame.plane::<T>()` itself reads via
479 // `samples()`), never assume the destination's full length
480 // matches `bytes` (see `WasapiCaptureSource::build_frame`'s own
481 // identical fix).
482 frame.data_mut(0)[..bytes.len()].copy_from_slice(bytes);
483 frame.set_pts(Some(self.samples_emitted));
484 self.samples_emitted += needed as i64;
485
486 if let Err(error) = self.pad.push(MediaBuffer::Audio(Arc::new(frame))) {
487 bus.post(
488 &self.pp_log,
489 BusEvent::Error {
490 element_type: ElementType::AudioMixer,
491 name: self.name.clone(),
492 error,
493 },
494 );
495 }
496 }
497}
498
499impl Element for AudioMixer {
500 fn name(&self) -> Arc<str> {
501 self.name.clone()
502 }
503
504 fn element_type(&self) -> ElementType {
505 ElementType::AudioMixer
506 }
507
508 fn pp_log(&self) -> &PpLog {
509 &self.pp_log
510 }
511
512 fn pp_log_mut(&mut self) -> &mut PpLog {
513 &mut self.pp_log
514 }
515}
516
517impl Source for AudioMixer {
518 fn src_pads(&mut self) -> &mut [SrcPad] {
519 std::slice::from_mut(&mut self.pad)
520 }
521}
522
523impl SourceElement for AudioMixer {
524 fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
525 pp_info!(self, "started");
526 let mut timeline = ActiveTimeline::new(Instant::now());
527 loop {
528 let outcome = drain_control(control, self, bus)?;
529 if outcome.stopped {
530 pp_info!(self, "stopped");
531 return Ok(());
532 }
533 timeline.account_pause(outcome.paused_for);
534 thread::sleep(TICK_INTERVAL);
535 self.mix_tick(timeline.elapsed(Instant::now()), bus);
536 }
537 }
538
539 fn seek(&mut self, _target: Duration) -> Result<Duration> {
540 Err(AudioMixerError::SeekUnsupported.into())
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use std::sync::{
547 Mutex as StdMutex,
548 atomic::{AtomicBool, Ordering},
549 };
550
551 use crate::pp_log::PpLog;
552
553 use super::*;
554 use crate::pipeline::Pipeline;
555
556 fn constant_frame(value: f32, samples: usize, rate: u32) -> ffmpeg::frame::Audio {
557 let mut frame = ffmpeg::frame::Audio::new(
558 ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
559 samples,
560 ffmpeg::ChannelLayout::default(1),
561 );
562 frame.set_rate(rate);
563 frame.plane_mut::<f32>(0).fill(value);
564 frame
565 }
566
567 struct RecordingSink {
568 pp_log: PpLog,
569 seen: Arc<StdMutex<Vec<f32>>>,
570 }
571
572 impl Element for RecordingSink {
573 fn name(&self) -> Arc<str> {
574 "recorder".into()
575 }
576 fn element_type(&self) -> ElementType {
577 ElementType::Other
578 }
579 fn pp_log(&self) -> &PpLog {
580 &self.pp_log
581 }
582 fn pp_log_mut(&mut self) -> &mut PpLog {
583 &mut self.pp_log
584 }
585 }
586
587 impl Sink for RecordingSink {
588 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
589 if let MediaBuffer::Audio(frame) = buf
590 && frame.samples() > 0
591 {
592 self.seen.lock().unwrap().push(frame.plane::<f32>(0)[0]);
593 }
594 Ok(())
595 }
596 fn control(&mut self, _msg: ControlMsg) -> Result<()> {
597 Ok(())
598 }
599 }
600
601 fn constant_stereo_frame(
602 left: f32,
603 right: f32,
604 samples: usize,
605 rate: u32,
606 ) -> ffmpeg::frame::Audio {
607 let mut frame = ffmpeg::frame::Audio::new(
608 ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
609 samples,
610 ffmpeg::ChannelLayout::default(2),
611 );
612 frame.set_rate(rate);
613 let bytes = frame.data_mut(0);
614 let floats =
615 // SAFETY: `bytes` is this frame's own plane, which FFmpeg aligns well past
616 // 4, and `samples * 2` f32s is what the frame was allocated for.
617 unsafe { std::slice::from_raw_parts_mut(bytes.as_mut_ptr() as *mut f32, samples * 2) };
618 for pair in floats.chunks_mut(2) {
619 pair[0] = left;
620 pair[1] = right;
621 }
622 frame
623 }
624
625 struct StereoRecordingSink {
626 pp_log: PpLog,
627 seen: Arc<StdMutex<Vec<(f32, f32)>>>,
628 }
629
630 impl Element for StereoRecordingSink {
631 fn name(&self) -> Arc<str> {
632 "stereo-recorder".into()
633 }
634 fn element_type(&self) -> ElementType {
635 ElementType::Other
636 }
637 fn pp_log(&self) -> &PpLog {
638 &self.pp_log
639 }
640 fn pp_log_mut(&mut self) -> &mut PpLog {
641 &mut self.pp_log
642 }
643 }
644
645 impl Sink for StereoRecordingSink {
646 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
647 if let MediaBuffer::Audio(frame) = buf
648 && frame.samples() > 0
649 {
650 // Raw bytes, not `plane::<f32>(0)`, for the same reason
651 // `InputBuffer::push` above does: `AudioMixer`'s output is
652 // packed multi-channel, and `plane::<T>()` only ever
653 // returns `samples()` elements regardless of channel
654 // count — reading channel 1 through it would silently
655 // read the wrong offset (still inside channel 0's data),
656 // not the second channel.
657 let samples = frame.samples();
658 let bytes = &frame.data(0)[..samples * 2 * 4];
659 // SAFETY: `bytes` is a prefix of the frame's plane, aligned by FFmpeg and
660 // cut to exactly `samples * 2 * 4` bytes just above.
661 let floats = unsafe {
662 std::slice::from_raw_parts(bytes.as_ptr() as *const f32, samples * 2)
663 };
664 self.seen.lock().unwrap().push((floats[0], floats[1]));
665 }
666 Ok(())
667 }
668 fn control(&mut self, _msg: ControlMsg) -> Result<()> {
669 Ok(())
670 }
671 }
672
673 /// Regression test for the packed-multichannel `InputBuffer::push` bug
674 /// (see the comment there): two stereo inputs, each with distinct,
675 /// asymmetric L/R values, should sum per-channel without the channels
676 /// bleeding into each other or silently dropping to zero. Before the
677 /// fix, `plane::<f32>(0)` under-read the resampled buffer (only
678 /// `samples()` interleaved scalars instead of `samples() * channels`),
679 /// which desynced every input's channel alignment.
680 #[test]
681 fn mixes_stereo_sources_without_channel_corruption() {
682 let (mixer, handle) = AudioMixer::new(
683 "mixer",
684 AudioMixerOptions {
685 sample_rate: 48000,
686 channels: 2,
687 },
688 );
689 let seen = Arc::new(StdMutex::new(Vec::new()));
690 let sink = StereoRecordingSink {
691 seen: seen.clone(),
692 pp_log: element_pp_log(ElementType::Other, "stereo-recorder", None),
693 };
694
695 let pipeline = Pipeline::new("mixer-stereo-test", mixer, |source, ctx| {
696 let branch = ctx.branch().to(Box::new(sink))?;
697 ctx.attach(source, 0, branch)?;
698 Ok(())
699 })
700 .expect("test pipeline wiring must succeed");
701 pipeline.run().unwrap();
702
703 let mut input_a = handle.add_source("a").expect("mixer still alive");
704 let mut input_b = handle.add_source("b").expect("mixer still alive");
705
706 let stop = Arc::new(AtomicBool::new(false));
707 let feeder_stop = stop.clone();
708 let feeder = std::thread::spawn(move || {
709 while !feeder_stop.load(Ordering::Relaxed) {
710 let _ = input_a.consume(MediaBuffer::Audio(Arc::new(constant_stereo_frame(
711 0.2, -0.1, 480, 48000,
712 ))));
713 let _ = input_b.consume(MediaBuffer::Audio(Arc::new(constant_stereo_frame(
714 0.1, -0.2, 480, 48000,
715 ))));
716 std::thread::sleep(Duration::from_millis(10));
717 }
718 });
719
720 std::thread::sleep(Duration::from_millis(300));
721 stop.store(true, Ordering::Relaxed);
722 feeder.join().unwrap();
723 pipeline.stop();
724 pipeline.bus().log_events();
725
726 let seen = seen.lock().unwrap();
727 assert!(
728 seen.len() > 5,
729 "expected several mixed frames, got {seen:?}"
730 );
731 let steady = &seen[3..seen.len() - 2];
732 for &(left, right) in steady {
733 assert!(
734 (left - 0.3).abs() < 0.01,
735 "expected left channel ~0.3, got {left} in {seen:?}"
736 );
737 assert!(
738 (right - -0.3).abs() < 0.01,
739 "expected right channel ~-0.3, got {right} in {seen:?}"
740 );
741 }
742 }
743
744 /// Two inputs, each pushing a constant `0.6` from their own thread
745 /// (standing in for two independent capture pipelines), should sum to
746 /// `1.2` and get hard-clipped to `1.0` — verifies resampling-on-first-
747 /// frame, cross-thread `consume`, summation, and clipping all work
748 /// together, not just in isolation.
749 #[test]
750 fn mixes_two_sources_and_hard_clips() {
751 let (mixer, handle) = AudioMixer::new(
752 "mixer",
753 AudioMixerOptions {
754 sample_rate: 48000,
755 channels: 1,
756 },
757 );
758 let seen = Arc::new(StdMutex::new(Vec::new()));
759 let sink = RecordingSink {
760 seen: seen.clone(),
761 pp_log: element_pp_log(ElementType::Other, "recorder", None),
762 };
763
764 let pipeline = Pipeline::new("mixer-test", mixer, |source, ctx| {
765 let branch = ctx.branch().to(Box::new(sink))?;
766 ctx.attach(source, 0, branch)?;
767 Ok(())
768 })
769 .expect("test pipeline wiring must succeed");
770 pipeline.run().unwrap();
771
772 let mut input_a = handle.add_source("a").expect("mixer still alive");
773 let mut input_b = handle.add_source("b").expect("mixer still alive");
774 assert_eq!(handle.source_count(), 2);
775
776 let stop = Arc::new(AtomicBool::new(false));
777 let feeder_stop = stop.clone();
778 let feeder = std::thread::spawn(move || {
779 while !feeder_stop.load(Ordering::Relaxed) {
780 let _ = input_a.consume(MediaBuffer::Audio(Arc::new(constant_frame(
781 0.6, 480, 48000,
782 ))));
783 let _ = input_b.consume(MediaBuffer::Audio(Arc::new(constant_frame(
784 0.6, 480, 48000,
785 ))));
786 std::thread::sleep(Duration::from_millis(10));
787 }
788 });
789
790 std::thread::sleep(Duration::from_millis(300));
791 stop.store(true, Ordering::Relaxed);
792 feeder.join().unwrap();
793 pipeline.stop();
794 pipeline.bus().log_events();
795
796 let seen = seen.lock().unwrap();
797 assert!(
798 seen.len() > 5,
799 "expected several mixed frames, got {seen:?}"
800 );
801 // Skip the first few ticks (the feeder thread may not have caught
802 // up yet) and the last couple (ticks after the feeder stopped but
803 // before `pipeline.stop()` landed correctly drain to silence) —
804 // check the steady state in between is clipped to 1.0.
805 let steady = &seen[3..seen.len() - 2];
806 for &value in steady {
807 assert!(
808 (value - 1.0).abs() < 0.01,
809 "expected hard-clipped ~1.0, got {value} in {seen:?}"
810 );
811 }
812 }
813
814 #[test]
815 fn removed_source_stops_contributing() {
816 let (mixer, handle) = AudioMixer::new(
817 "mixer",
818 AudioMixerOptions {
819 sample_rate: 48000,
820 channels: 1,
821 },
822 );
823 let seen = Arc::new(StdMutex::new(Vec::new()));
824 let sink = RecordingSink {
825 seen: seen.clone(),
826 pp_log: element_pp_log(ElementType::Other, "recorder", None),
827 };
828 let pipeline = Pipeline::new("mixer-test-2", mixer, |source, ctx| {
829 let branch = ctx.branch().to(Box::new(sink))?;
830 ctx.attach(source, 0, branch)?;
831 Ok(())
832 })
833 .expect("test pipeline wiring must succeed");
834 pipeline.run().unwrap();
835
836 let mut input_a = handle.add_source("a").unwrap();
837 input_a
838 .consume(MediaBuffer::Audio(Arc::new(constant_frame(
839 0.5, 480, 48000,
840 ))))
841 .unwrap();
842 std::thread::sleep(Duration::from_millis(100));
843 handle.remove_source("a");
844 assert_eq!(handle.source_count(), 0);
845 seen.lock().unwrap().clear();
846
847 std::thread::sleep(Duration::from_millis(100));
848 pipeline.stop();
849 pipeline.bus().log_events();
850
851 assert!(
852 seen.lock().unwrap().iter().all(|&v| v == 0.0),
853 "removed source must not keep contributing: {:?}",
854 *seen.lock().unwrap()
855 );
856 }
857
858 /// Regression test: a capture pipeline ending via `Stop` — the only
859 /// shutdown signal a live source like `WasapiCaptureSource` ever sends,
860 /// since it never reaches `Eos` on its own — used to leave a stale
861 /// entry in the mixer's input map forever, because only `Eos` cleared
862 /// it. `Sink::control` is what a `Queue`/`Pipeline` actually calls on
863 /// `Stop` (mirrored by hand here, since this input isn't wired into a
864 /// real second `Pipeline` in this test), not `consume`.
865 #[test]
866 fn stopped_source_is_removed_without_an_explicit_remove_source_call() {
867 let (mixer, handle) = AudioMixer::new(
868 "mixer",
869 AudioMixerOptions {
870 sample_rate: 48000,
871 channels: 1,
872 },
873 );
874 let seen = Arc::new(StdMutex::new(Vec::new()));
875 let sink = RecordingSink {
876 seen: seen.clone(),
877 pp_log: element_pp_log(ElementType::Other, "recorder", None),
878 };
879 let pipeline = Pipeline::new("mixer-test-3", mixer, |source, ctx| {
880 let branch = ctx.branch().to(Box::new(sink))?;
881 ctx.attach(source, 0, branch)?;
882 Ok(())
883 })
884 .expect("test pipeline wiring must succeed");
885 pipeline.run().unwrap();
886
887 let mut input_a = handle.add_source("a").unwrap();
888 input_a
889 .consume(MediaBuffer::Audio(Arc::new(constant_frame(
890 0.5, 480, 48000,
891 ))))
892 .unwrap();
893 assert_eq!(handle.source_count(), 1);
894
895 // What a `Queue`/`Pipeline` actually calls on this input's own
896 // `Sink` when its upstream capture pipeline is stopped — never
897 // `consume(Eos)`, since `WasapiCaptureSource` doesn't send one.
898 input_a.control(ControlMsg::Stop).unwrap();
899
900 assert_eq!(
901 handle.source_count(),
902 0,
903 "Stop should remove the input immediately, same as remove_source"
904 );
905
906 pipeline.stop();
907 pipeline.bus().log_events();
908 }
909
910 /// Re-registering a name replaces its input buffer, but callers may
911 /// still hold the sink returned for the old registration. Every late
912 /// operation through that stale sink must be inert rather than being
913 /// redirected to (or deleting) the replacement merely because the map
914 /// key is the same.
915 #[test]
916 fn replacing_an_input_by_name_invalidates_the_stale_sink() {
917 let (_mixer, handle) = AudioMixer::new(
918 "mixer",
919 AudioMixerOptions {
920 sample_rate: 48000,
921 channels: 1,
922 },
923 );
924 let mut stale = handle.add_source("mic").expect("mixer still alive");
925 let mut current = handle.add_source("mic").expect("mixer still alive");
926 assert_eq!(handle.source_count(), 1);
927
928 stale
929 .consume(MediaBuffer::Audio(Arc::new(constant_frame(
930 0.75, 480, 48000,
931 ))))
932 .unwrap();
933 stale.consume(MediaBuffer::Eos).unwrap();
934 stale.control(ControlMsg::Stop).unwrap();
935
936 assert_eq!(
937 handle.source_count(),
938 1,
939 "a stale sink's Stop must not remove its replacement"
940 );
941 let shared = handle.shared.upgrade().expect("mixer still alive");
942 {
943 let inputs = shared.inputs.lock().unwrap();
944 let input = inputs.get("mic").expect("replacement remains registered");
945 assert!(
946 input.resampler.is_none() && input.samples.is_empty(),
947 "stale audio must not enter the replacement buffer"
948 );
949 assert!(!input.eos, "stale Eos must not mark the replacement ended");
950 }
951
952 // The current sink still owns the registration and therefore
953 // remains fully functional.
954 current
955 .consume(MediaBuffer::Audio(Arc::new(constant_frame(
956 0.25, 480, 48000,
957 ))))
958 .unwrap();
959 current.consume(MediaBuffer::Eos).unwrap();
960 {
961 let inputs = shared.inputs.lock().unwrap();
962 let input = inputs.get("mic").expect("replacement remains registered");
963 assert!(input.resampler.is_some(), "current audio was not accepted");
964 assert!(input.eos, "current Eos was not accepted");
965 }
966
967 current.control(ControlMsg::Stop).unwrap();
968 assert_eq!(handle.source_count(), 0);
969 }
970
971 /// A misrouted `Packet`/`Video` buffer used to be silently logged and
972 /// dropped — no `BusEvent::Error`, no way for a misconfigured pipeline
973 /// to ever find out. Matches the typed-error pattern every other
974 /// `Sink` in this codebase already uses for a wrong `MediaBuffer`
975 /// variant (e.g. `Mp4MuxerStreamSink`).
976 #[test]
977 fn rejects_buffers_that_are_neither_audio_nor_eos() {
978 let (mixer, handle) = AudioMixer::new(
979 "mixer",
980 AudioMixerOptions {
981 sample_rate: 48000,
982 channels: 2,
983 },
984 );
985 let mut input = handle.add_source("a").expect("mixer still alive");
986
987 let error = input
988 .consume(MediaBuffer::Packet(Arc::new(ffmpeg::Packet::empty())))
989 .expect_err("a Packet buffer must be rejected, not silently dropped");
990 assert!(
991 matches!(
992 error,
993 crate::error::Error::AudioMixerError(AudioMixerError::UnsupportedBuffer("Packet"))
994 ),
995 "unexpected error: {error:?}"
996 );
997
998 drop(mixer);
999 }
1000}