Skip to main content

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}