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 contract::{InputContract, MediaKind, MemoryDomain, OutputContract, PortContract},
19 control::{ControlMsg, ControlReceiver, drain_control},
20 element::{Element, ElementType, Sink, Source, SourceElement, element_pp_log},
21 elements::AudioFormat,
22 elements::filter::audio_resampler::AudioFrameResampler,
23 error::Result,
24 pad::SrcPad,
25 schedule::ActiveTimeline,
26};
27
28/// How often [`AudioMixer::run`] mixes and emits a combined frame — same
29/// role as [`crate::elements::DxgiCaptureSource`]'s own `POLL_GRANULARITY`/
30/// `crate::elements::WasapiCaptureSource`'s `POLL_INTERVAL`: bounds `Stop`
31/// latency and sets the mixer's own output granularity.
32const TICK_INTERVAL: Duration = Duration::from_millis(20);
33
34/// Errors specific to `AudioMixer`. Converts into the crate-wide `Error`
35/// via `?` (see [`crate::error::Error`]).
36#[derive(Debug, ThisError)]
37pub enum AudioMixerError {
38 /// FFmpeg rejected resampler creation or audio conversion.
39 #[error("ffmpeg error: {0}")]
40 Ffmpeg(#[from] ffmpeg_next::Error),
41
42 /// Seeking was requested on a live mixer with no stored timeline.
43 #[error("AudioMixer doesn't support seeking a live mix")]
44 SeekUnsupported,
45
46 /// An input sink received a buffer other than decoded audio or end-of-stream.
47 #[error("AudioMixer inputs only accept Audio or Eos buffers, got {0}")]
48 UnsupportedBuffer(&'static str),
49}
50
51/// Construction-time options for [`AudioMixer::new`] — the mixer's fixed
52/// *output* format. Every input is resampled to match this on the way in
53/// (see `InputBuffer::push`); the mixer never adapts to whatever an
54/// input happens to produce.
55#[derive(Debug, Clone, Copy)]
56pub struct AudioMixerOptions {
57 /// Sample rate of every mixed output frame, in hertz.
58 pub sample_rate: u32,
59 /// Channel count of every mixed output frame.
60 pub channels: u16,
61}
62
63/// The format every input is resampled to and every output frame carries.
64///
65/// One value rather than three fields, packed into a word, so an input
66/// resampling on its own thread can never catch a sample rate from one
67/// setting and a channel count from another. `format` and `channel_layout`
68/// are derived from it rather than stored: this mixer works in interleaved
69/// `f32` and the layout is whatever is default for the channel count, so a
70/// stored copy of either could only ever disagree.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct MixFormat {
73 pub sample_rate: u32,
74 pub channels: u16,
75}
76
77impl MixFormat {
78 /// Interleaved `f32` — see [`AudioMixer`] on why the mix is summed in
79 /// one fixed sample format rather than whatever arrived.
80 fn sample_format(self) -> ffmpeg::format::Sample {
81 ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed)
82 }
83
84 fn channel_layout(self) -> ffmpeg::ChannelLayout {
85 ffmpeg::ChannelLayout::default(self.channels as i32)
86 }
87
88 fn pack(self) -> u64 {
89 ((self.sample_rate as u64) << 16) | self.channels as u64
90 }
91
92 fn unpack(packed: u64) -> Self {
93 Self {
94 sample_rate: (packed >> 16) as u32,
95 channels: packed as u16,
96 }
97 }
98}
99
100/// Where the sample deficit is measured from.
101///
102/// Not `Duration::ZERO` and zero samples, because the mix format can change
103/// while this runs and `elapsed × sample_rate` only means anything against
104/// the rate that produced the count it is compared to — see
105/// [`AudioMixer::mix_tick`].
106#[derive(Debug, Clone, Copy)]
107struct MixAnchor {
108 format: MixFormat,
109 elapsed: Duration,
110 samples: i64,
111}
112
113/// The mix format, shared between the mixer's own tick and every input
114/// resampling into it from another thread.
115#[derive(Debug)]
116struct SharedMixFormat(AtomicU64);
117
118impl SharedMixFormat {
119 fn new(format: MixFormat) -> Self {
120 Self(AtomicU64::new(format.pack()))
121 }
122
123 fn get(&self) -> MixFormat {
124 MixFormat::unpack(self.0.load(Ordering::Relaxed))
125 }
126
127 /// `false` for a format nothing could be resampled to, which leaves the
128 /// running one alone rather than a mixer summing into nothing.
129 fn set(&self, format: MixFormat) -> bool {
130 if format.sample_rate == 0 || format.channels == 0 {
131 return false;
132 }
133 self.0.store(format.pack(), Ordering::Relaxed);
134 true
135 }
136}
137
138/// One input's own resampler and accumulated (already-resampled,
139/// interleaved `f32`) samples, waiting to be drained by the next
140/// [`AudioMixer::mix_tick`]. The resampler is built lazily from the first
141/// frame this input ever sees (its `format`/`channel_layout`/`rate`
142/// self-describe — no need for [`MixerHandle::add_source`] to be told
143/// this upfront).
144struct InputBuffer {
145 /// Identity of this particular registration. The name can be reused,
146 /// but an older sink must not be allowed to touch its replacement.
147 id: u64,
148 /// Built lazily from the first frame, and rebuilt when the mix format
149 /// moves under it. Kept beside it rather than walked and invalidated
150 /// from outside: an input that notices for itself needs no lock held
151 /// across somebody else's state, and an input registered after a change
152 /// is correct without being told. The *arriving* format moving is the
153 /// resampler's own business — [`AudioFrameResampler`] rebuilds for that,
154 /// draining what the old context still held first.
155 resampler: Option<(MixFormat, AudioFrameResampler)>,
156 samples: VecDeque<f32>,
157 /// Set once this input's `Eos` arrives — [`AudioMixer::mix_tick`]
158 /// drops the input entirely once it's both `eos` and fully drained,
159 /// same as a `Tee` branch dropping out once removed. Unlike a fixed
160 /// two-track muxer, `AudioMixer` has no fixed input count to wait on:
161 /// one input reaching `Eos` just means the mix continues without it.
162 eos: bool,
163}
164
165impl InputBuffer {
166 fn push(
167 &mut self,
168 frame: &ffmpeg::frame::Audio,
169 to: MixFormat,
170 ) -> std::result::Result<(), AudioMixerError> {
171 // Rebuilt when the mix format has moved. A resampler is fixed at
172 // both ends when it is created, so one built for the old mix format
173 // would keep producing it — silently, and into a mix that no longer
174 // wants it.
175 if self
176 .resampler
177 .as_ref()
178 .is_none_or(|(built, _)| *built != to)
179 {
180 self.resampler = Some((
181 to,
182 AudioFrameResampler::new(AudioFormat::new(
183 to.sample_format(),
184 to.sample_rate,
185 to.channels,
186 )),
187 ));
188 }
189 // Through the shared engine rather than a `Context` of this
190 // element's own, for the sizing above all. Handed an unallocated
191 // output frame, `Context::run` gives it room for exactly as many
192 // samples as went in — which is short by the rate ratio whenever an
193 // input is *slower* than the mix, so 44.1kHz media into a 48kHz mix
194 // left 8% of every frame behind in libswresample's own delay. It is
195 // not dropped, which would merely be a click: it comes back on the
196 // next call, which is short by the same 8% again, so the input feeds
197 // the mix at 92% of real time. The mixer fills the shortfall of each
198 // tick with silence — a chop at the tick rate — and what does arrive
199 // falls further behind its own picture every second.
200 let resampled = {
201 let (_, resampler) = self
202 .resampler
203 .as_mut()
204 .expect("just built if it was missing or stale");
205 resampler.run(frame)?
206 };
207 for output in &resampled {
208 // Raw bytes, not `plane::<f32>(0)`: `ffmpeg_next`'s `plane::<T>()`
209 // always returns exactly `output.samples()` elements of type `T`,
210 // which for **packed multi-channel** data (this mixer's own fixed
211 // `Sample::F32(Packed)` target — see `AudioMixer::new`) covers only
212 // the first `samples()` of the real `samples() * channels`
213 // interleaved scalars actually in the buffer, silently dropping
214 // every channel past the first once the mix layout has more than
215 // one. Same fix, and the same root cause, as
216 // `crate::elements::SwAudioEncoder`'s own `absorb_resampled`
217 // (found while building that element — this call predates it).
218 let samples = output.samples();
219 let channels = to.channels as usize;
220 let bytes = &output.data(0)[..samples * channels * 4];
221 let interleaved =
222 // SAFETY: `bytes` is a prefix of an FFmpeg audio plane, which is aligned
223 // well past 4 and whose length here is an exact multiple of four bytes —
224 // `samples * channels * 4`.
225 unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const f32, bytes.len() / 4) };
226 self.samples.extend(interleaved.iter().copied());
227 }
228 Ok(())
229 }
230}
231
232/// Shared state between [`AudioMixer`] and every [`MixerHandle`]/
233/// [`MixerInputSink`] derived from it — just the input map, behind one
234/// lock (same granularity [`crate::elements::Tee`]'s own `TeeShared::pads`
235/// uses: one lock for the whole collection, not one per entry, since a mix
236/// tick already needs to visit every input together anyway).
237struct MixerShared {
238 inputs: Mutex<HashMap<Arc<str>, InputBuffer>>,
239 /// What every input resamples to and every output frame carries. Here
240 /// rather than on the mixer, because the inputs reading it are on other
241 /// threads and this is the only thing they share with it.
242 format: SharedMixFormat,
243 /// Issues a distinct identity for every `add_source` call, including
244 /// replacements registered under an existing name.
245 next_input_id: AtomicU64,
246}
247
248/// A cheaply-cloneable handle for adding or removing an [`AudioMixer`]'s
249/// input sources while the pipeline is running — the mirror image of
250/// [`crate::elements::TeeHandle`]: `Tee` lets you attach/detach *outputs*
251/// from another thread; this lets you attach/detach *inputs*. Keeps only a
252/// [`Weak`] reference for the same reason `TeeHandle` does: retaining a
253/// handle after the mixer's own pipeline finishes must not keep its
254/// internal state alive forever, and every operation becomes a harmless
255/// no-op once the mixer is gone.
256#[derive(Clone)]
257pub struct MixerHandle {
258 shared: Weak<MixerShared>,
259}
260
261impl MixerHandle {
262 /// Registers a new input under `name` and returns a [`Sink`] to use as
263 /// a detached branch terminal. Build and attach it inside that source's
264 /// own `Pipeline::new` wiring closure — a
265 /// *different* pipeline/thread than this mixer's own, which is exactly
266 /// the point). `None` once the mixer itself is gone. Calling this
267 /// again with a name already in use replaces that input outright
268 /// (whatever it had buffered is dropped) rather than erroring — same
269 /// "just do what was asked" spirit as `HashMap::insert`. A sink from
270 /// the previous registration then becomes inert: its data, `Eos`, and
271 /// `Stop` cannot affect the replacement sharing its name.
272 ///
273 /// The input endpoint appears in the upstream source pipeline's graph
274 /// when attached through [`crate::element::Context::attach`]. The graph
275 /// intentionally does not invent a cross-pipeline edge to the mixer.
276 pub fn add_source(&self, name: impl Into<String>) -> Option<Box<dyn Sink>> {
277 let shared = self.shared.upgrade()?;
278 let name: Arc<str> = name.into().into();
279 let id = shared.next_input_id.fetch_add(1, Ordering::Relaxed);
280 shared.inputs.lock().unwrap().insert(
281 name.clone(),
282 InputBuffer {
283 id,
284 resampler: None,
285 samples: VecDeque::new(),
286 eos: false,
287 },
288 );
289 Some(Box::new(MixerInputSink {
290 name: name.clone(),
291 id,
292 pp_log: element_pp_log(ElementType::AudioMixerInput, &name, None),
293 shared: self.shared.clone(),
294 }))
295 }
296
297 /// The format the mix is summed into and emitted at.
298 ///
299 /// `None` once the mixer is gone.
300 pub fn mix_format(&self) -> Option<MixFormat> {
301 Some(self.shared.upgrade()?.format.get())
302 }
303
304 /// Changes it, from the next tick.
305 ///
306 /// Returns `false` for a format nothing could be resampled to, and for a
307 /// mixer that has already been dropped.
308 ///
309 /// # What moves with it
310 ///
311 /// Every input rebuilds its own resampler when it next pushes — each
312 /// remembers what its own was built for, so none has to be found and
313 /// invalidated from here, and an input registered after this call is
314 /// correct without being told.
315 ///
316 /// [`AudioMixer::time_base`] is `1/sample_rate`, and the output `pts` is
317 /// a running sample count in those units. So changing the rate re-means
318 /// every timestamp after it, while the ones already downstream were
319 /// stamped under the old one. Nothing here can repair that: a muxer
320 /// holding a time base from `avformat_write_header` will not be told, and
321 /// an encoder was opened for a channel count.
322 ///
323 /// So this is safe exactly while nothing downstream is reading timestamps
324 /// — a level meter, an idle mixer — and it is the caller's to know. In
325 /// practice: change it between recordings, not during one. The mixer
326 /// itself keeps running either way, which is the point: its `pts` stays
327 /// continuous, and a rate change is not a reason to restart the one
328 /// element every audio source in the application is registered with.
329 pub fn set_mix_format(&self, format: MixFormat) -> bool {
330 self.shared
331 .upgrade()
332 .is_some_and(|shared| shared.format.set(format))
333 }
334
335 /// Drops `name`'s input immediately, discarding whatever it had
336 /// buffered — a no-op if `name` isn't currently registered, or the
337 /// mixer is gone.
338 pub fn remove_source(&self, name: &str) {
339 if let Some(shared) = self.shared.upgrade() {
340 shared.inputs.lock().unwrap().remove(name);
341 }
342 }
343
344 /// Returns the number of inputs currently registered with the live mixer.
345 ///
346 /// Returns zero after the mixer has been dropped.
347 pub fn source_count(&self) -> usize {
348 self.shared
349 .upgrade()
350 .map(|shared| shared.inputs.lock().unwrap().len())
351 .unwrap_or(0)
352 }
353}
354
355/// One [`AudioMixer`] input, returned by [`MixerHandle::add_source`].
356/// Resamples every incoming frame to the mixer's fixed output format and
357/// appends it to this input's own buffer — the actual summing happens
358/// later, on [`AudioMixer::run`]'s own thread, not here. `consume` runs on
359/// whatever thread is driving the *upstream* source this got linked to
360/// (a different pipeline's own thread, in the normal case), so every
361/// access to the shared input map goes through `MixerShared`'s lock.
362pub struct MixerInputSink {
363 pp_log: PpLog,
364 name: Arc<str>,
365 /// Identity returned by the corresponding `add_source` call. Compared
366 /// with the map entry before every mutation so a stale sink cannot
367 /// write to or remove a same-name replacement.
368 id: u64,
369 shared: Weak<MixerShared>,
370}
371
372// SAFETY: `ffmpeg::ChannelLayout` wraps `AVChannelLayout`, which carries a
373// non-`Send` custom-layout pointer only for `AV_CHANNEL_ORDER_CUSTOM`
374// layouts. Every `ChannelLayout` here comes from `ChannelLayout::default`
375// (see `AudioMixer::new`) — a plain native layout, that pointer always
376// null — so there's nothing thread-unsafe actually being sent.
377unsafe impl Send for MixerInputSink {}
378
379impl Element for MixerInputSink {
380 fn name(&self) -> Arc<str> {
381 self.name.clone()
382 }
383
384 fn element_type(&self) -> ElementType {
385 ElementType::AudioMixerInput
386 }
387
388 fn pp_log(&self) -> &PpLog {
389 &self.pp_log
390 }
391
392 fn pp_log_mut(&mut self) -> &mut PpLog {
393 &mut self.pp_log
394 }
395}
396
397impl Sink for MixerInputSink {
398 /// Every input is summed sample by sample, so each carries decoded
399 /// audio just as the mixed output does.
400 fn input_contract(&self) -> InputContract {
401 InputContract::Fixed(PortContract::frame(
402 MediaKind::AudioFrame,
403 MemoryDomain::System,
404 ))
405 }
406
407 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
408 let Some(shared) = self.shared.upgrade() else {
409 return Ok(()); // mixer's own pipeline already ended — nothing to feed
410 };
411 match buf {
412 MediaBuffer::Audio(frame) => {
413 let mut inputs = shared.inputs.lock().unwrap();
414 if let Some(input) = inputs.get_mut(&self.name)
415 && input.id == self.id
416 {
417 // Read per buffer rather than copied in at `add_source`: this
418 // sink lives on the input's own thread, and a mix format
419 // changed while it is running has to reach it there.
420 input.push(&frame, shared.format.get())?;
421 }
422 // Absent means `remove_source` raced ahead of this frame;
423 // an ID mismatch means this name was replaced. Dropping
424 // the frame is correct in both cases.
425 }
426 MediaBuffer::Eos => {
427 let mut inputs = shared.inputs.lock().unwrap();
428 if let Some(input) = inputs.get_mut(&self.name)
429 && input.id == self.id
430 {
431 input.eos = true;
432 }
433 }
434 other => {
435 pp_error!(self, "unsupported buffer: expected Audio or Eos");
436 return Err(AudioMixerError::UnsupportedBuffer(other.kind()).into());
437 }
438 }
439 Ok(())
440 }
441
442 /// No downstream of its own to cascade to — this is a leaf input slot,
443 /// not a passthrough. `Stop` removes this input immediately, same as
444 /// [`MixerHandle::remove_source`]: `Stop` means abandon now, not drain
445 /// to a natural `Eos` (see `ControlMsg::Stop`'s own docs), and for a
446 /// live capture source — `WasapiCaptureSource`
447 /// included — `Stop` is the *only* shutdown signal that ever arrives;
448 /// it never reaches `Eos` on its own. Relying on `Eos` alone to clean
449 /// up (as an earlier version of this did) left a stale entry in
450 /// `shared.inputs` forever whenever a caller stopped its capture
451 /// pipeline normally instead of remembering to call
452 /// `MixerHandle::remove_source` by hand. `Pause`/`Resume`/`Seek` need
453 /// no handling here — this input has no thread or queue of its own to
454 /// freeze/resume, and a live capture source doesn't seek. Removal is
455 /// conditional on the registration ID: a late `Stop` from a replaced
456 /// sink must not remove the newer input using the same name.
457 fn control(&mut self, msg: ControlMsg) -> Result<()> {
458 if msg == ControlMsg::Stop
459 && let Some(shared) = self.shared.upgrade()
460 {
461 let mut inputs = shared.inputs.lock().unwrap();
462 if inputs
463 .get(&self.name)
464 .is_some_and(|input| input.id == self.id)
465 {
466 inputs.remove(&self.name);
467 }
468 }
469 Ok(())
470 }
471}
472
473/// Sums an arbitrary, dynamically-changing number of audio sources into
474/// one output stream — the structural mirror of [`crate::elements::Tee`]:
475/// `Tee` is one input fanned out to a dynamic set of outputs behind a
476/// lock; `AudioMixer` is a dynamic set of inputs (added/removed via
477/// [`MixerHandle`], from whatever thread each one's own source pipeline
478/// runs on) summed into one output. Unlike `Tee`, which is a passive
479/// [`Sink`] driven entirely by whatever calls `consume`, `AudioMixer` has
480/// to drive itself: it's a [`SourceElement`] with its own `run` thread,
481/// ticking every `TICK_INTERVAL` to sum however many samples each
482/// currently-attached input has ready — because mixing has to keep
483/// producing *something* on a steady clock even when some (or all) inputs
484/// have gone quiet, the same reason
485/// `WasapiCaptureSource` synthesizes silence for gaps
486/// rather than just emitting nothing.
487///
488/// Every input is resampled to this mixer's own `sample_rate`/`channels`,
489/// which [`MixerHandle::set_mix_format`] can change while it runs — each
490/// input notices for itself and rebuilds its own resampler. The sample
491/// format is fixed at `Sample::F32(Packed)`: float headroom during
492/// summation, the same reason real mixing consoles work in float even when
493/// everything else is integer PCM. An input
494/// short on samples for a given tick contributes silence for the
495/// shortfall rather than blocking the whole mix. Samples are summed and
496/// **hard-clipped** to `[-1.0, 1.0]`, not averaged: two or three sources
497/// is the expected case, where clipping is rare, and averaging would
498/// quietly lower the whole mix's volume every time a source count
499/// changes — a caller who wants headroom can lower an individual input's
500/// gain before it ever reaches the mixer (not implemented — nothing needs
501/// it yet).
502///
503/// `pts` is a plain, always-continuous sample count (see
504/// [`AudioMixer::time_base`]), advancing in lockstep with wall-clock time
505/// regardless of which/how many inputs are actually contributing at any
506/// moment.
507///
508/// Runs until `Stop` — never reaches `Eos` on its own, same as every
509/// other live source in this crate; an individual input reaching `Eos` or
510/// being removed just drops out of future ticks, it doesn't end the mix.
511pub struct AudioMixer {
512 pp_log: PpLog,
513 name: Arc<str>,
514 shared: Arc<MixerShared>,
515 pad: SrcPad,
516 /// Cumulative sample count across every emitted frame — see
517 /// [`AudioMixer::time_base`].
518 samples_emitted: i64,
519 /// What the deficit is measured from — see [`MixAnchor`].
520 anchor: MixAnchor,
521}
522
523// SAFETY: see `MixerInputSink`'s own `unsafe impl Send` docs — same
524// reasoning, `channel_layout` here is always `ChannelLayout::default`'s
525// plain native layout.
526unsafe impl Send for AudioMixer {}
527
528impl AudioMixer {
529 /// Starts with no inputs — add some via the returned [`MixerHandle`]
530 /// before (or any time after) wiring `AudioMixer` into a
531 /// [`crate::pipeline::Pipeline`] (`Pipeline::new` registers it as that
532 /// pipeline's own source automatically, same as any other
533 /// [`SourceElement`] — no [`crate::element::Context`] needed here,
534 /// unlike [`crate::elements::TeeBuilder::new`], since `AudioMixer` has no
535 /// chains of its own for a handle to build).
536 pub fn new(name: impl Into<String>, options: AudioMixerOptions) -> (Self, MixerHandle) {
537 let name: Arc<str> = name.into().into();
538 let pp_log = element_pp_log(ElementType::AudioMixer, &name, None);
539 pp_info!(
540 pp_log: &pp_log,
541 "created: {}Hz, {} channel(s)",
542 options.sample_rate,
543 options.channels
544 );
545
546 let format = MixFormat {
547 sample_rate: options.sample_rate,
548 channels: options.channels,
549 };
550 let shared = Arc::new(MixerShared {
551 inputs: Mutex::new(HashMap::new()),
552 format: SharedMixFormat::new(format),
553 next_input_id: AtomicU64::new(0),
554 });
555 let pad = SrcPad::with_contract(
556 format!("{name}_src"),
557 OutputContract::Fixed(PortContract::frame(
558 MediaKind::AudioFrame,
559 MemoryDomain::System,
560 )),
561 );
562 (
563 Self {
564 name: name.clone(),
565 pp_log,
566 shared: shared.clone(),
567 pad,
568 samples_emitted: 0,
569 anchor: MixAnchor {
570 format,
571 elapsed: Duration::ZERO,
572 samples: 0,
573 },
574 },
575 MixerHandle {
576 shared: Arc::downgrade(&shared),
577 },
578 )
579 }
580
581 /// The unit each emitted frame's `pts` is expressed in.
582 pub fn time_base(&self) -> ffmpeg::Rational {
583 ffmpeg::Rational::new(1, self.shared.format.get().sample_rate as i32)
584 }
585
586 /// Sums however many samples are needed to keep `samples_emitted` in
587 /// lockstep with `elapsed` (a no-op if nothing's owed yet — same
588 /// wall-clock-deficit shape as
589 /// [`crate::elements::WasapiCaptureSource::fill_silence_gap`], just
590 /// summing real contributions from every input instead of emitting
591 /// pure silence). `elapsed` already excludes time spent frozen inside
592 /// `Pause` (see [`crate::schedule::ActiveTimeline`]) so a `Pause`/
593 /// `Resume` pair doesn't get summed as a burst of owed samples the
594 /// moment playback resumes. Drops any input that's both `eos` and
595 /// fully drained — it contributed its last real samples on a previous
596 /// tick and has nothing left to give.
597 fn mix_tick(&mut self, elapsed: Duration, bus: &Bus) {
598 let format = self.shared.format.get();
599 // Re-anchored when the format moves. The deficit below is
600 // `elapsed × sample_rate` against a running count, and those two are
601 // only comparable while the rate that produced them is the same one:
602 // measured straight, a drop from 48 kHz to 44.1 makes `expected` fall
603 // *below* what has already been emitted, and the mixer goes silent
604 // for the minute it takes the new rate to catch up. So the count is
605 // kept — `pts` must stay continuous — and only the deficit starts
606 // again, from here.
607 if format != self.anchor.format {
608 pp_info!(
609 self,
610 "mix format is now {}Hz, {} channel(s)",
611 format.sample_rate,
612 format.channels
613 );
614 self.anchor = MixAnchor {
615 format,
616 elapsed,
617 samples: self.samples_emitted,
618 };
619 }
620 let channels = format.channels as usize;
621 let since = elapsed.saturating_sub(self.anchor.elapsed);
622 let expected =
623 self.anchor.samples + (since.as_secs_f64() * format.sample_rate as f64) as i64;
624 let needed = (expected - self.samples_emitted).max(0) as usize;
625 if needed == 0 {
626 return;
627 }
628 let mut mixed = vec![0f32; needed * channels];
629 {
630 let mut inputs = self.shared.inputs.lock().unwrap();
631 inputs.retain(|_, input| !(input.eos && input.samples.is_empty()));
632 for input in inputs.values_mut() {
633 let take = mixed.len().min(input.samples.len());
634 for (slot, sample) in mixed.iter_mut().zip(input.samples.iter()) {
635 *slot += *sample;
636 }
637 input.samples.drain(0..take);
638 }
639 }
640 for sample in &mut mixed {
641 *sample = sample.clamp(-1.0, 1.0);
642 }
643
644 let mut frame =
645 ffmpeg::frame::Audio::new(format.sample_format(), needed, format.channel_layout());
646 frame.set_rate(format.sample_rate);
647 // SAFETY: viewing an `f32` slice as bytes, which is always aligned and
648 // exactly `size_of_val` long. The read is only as wide as `mixed` itself;
649 // what the *destination* can take is the separate bound the comment below
650 // describes.
651 let bytes = unsafe {
652 std::slice::from_raw_parts(mixed.as_ptr() as *const u8, std::mem::size_of_val(&*mixed))
653 };
654 // `frame.data_mut(0)`'s length is FFmpeg's own padded linesize,
655 // not necessarily `mixed.len() * 4` exactly — only ever write that
656 // tight amount (same bound `frame.plane::<T>()` itself reads via
657 // `samples()`), never assume the destination's full length
658 // matches `bytes` (see `WasapiCaptureSource::build_frame`'s own
659 // identical fix).
660 frame.data_mut(0)[..bytes.len()].copy_from_slice(bytes);
661 frame.set_pts(Some(self.samples_emitted));
662 self.samples_emitted += needed as i64;
663
664 if let Err(error) = self.pad.push(MediaBuffer::Audio(Arc::new(frame))) {
665 bus.post(
666 &self.pp_log,
667 BusEvent::Error {
668 element_type: ElementType::AudioMixer,
669 name: self.name.clone(),
670 error,
671 },
672 );
673 }
674 }
675}
676
677impl Element for AudioMixer {
678 fn name(&self) -> Arc<str> {
679 self.name.clone()
680 }
681
682 fn element_type(&self) -> ElementType {
683 ElementType::AudioMixer
684 }
685
686 fn pp_log(&self) -> &PpLog {
687 &self.pp_log
688 }
689
690 fn pp_log_mut(&mut self) -> &mut PpLog {
691 &mut self.pp_log
692 }
693}
694
695impl Source for AudioMixer {
696 fn src_pads(&mut self) -> &mut [SrcPad] {
697 std::slice::from_mut(&mut self.pad)
698 }
699}
700
701impl SourceElement for AudioMixer {
702 fn is_live(&self) -> bool {
703 true
704 }
705
706 fn is_seekable(&self) -> bool {
707 false
708 }
709
710 fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
711 pp_info!(self, "started");
712 let mut timeline = ActiveTimeline::new(Instant::now());
713 loop {
714 let outcome = drain_control(control, self, bus)?;
715 if outcome.stopped {
716 pp_info!(self, "stopped");
717 return Ok(());
718 }
719 timeline.account_pause(outcome.paused_for);
720 thread::sleep(TICK_INTERVAL);
721 self.mix_tick(timeline.elapsed(Instant::now()), bus);
722 }
723 }
724
725 fn seek(&mut self, _target: Duration) -> Result<Duration> {
726 Err(AudioMixerError::SeekUnsupported.into())
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use std::sync::{
733 Mutex as StdMutex,
734 atomic::{AtomicBool, Ordering},
735 };
736
737 use crate::pp_log::PpLog;
738
739 use super::*;
740 use crate::pipeline::Pipeline;
741
742 fn constant_frame(value: f32, samples: usize, rate: u32) -> ffmpeg::frame::Audio {
743 let mut frame = ffmpeg::frame::Audio::new(
744 ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
745 samples,
746 ffmpeg::ChannelLayout::default(1),
747 );
748 frame.set_rate(rate);
749 frame.plane_mut::<f32>(0).fill(value);
750 frame
751 }
752
753 /// A file's 44.1kHz sound into a 48kHz mix — the ordinary case, since
754 /// most media is 44.1kHz and a capture device is usually 48kHz.
755 ///
756 /// What the mixer takes per tick is fixed by the wall clock, so an input
757 /// resampled *short* is not merely quieter: the shortfall is filled with
758 /// silence at the tick rate, and what does arrive falls further behind
759 /// its own picture every second. Handed an unallocated output frame,
760 /// libswresample sizes it for as many samples as went in, which is 8%
761 /// short at this ratio.
762 #[test]
763 fn a_slower_input_arrives_at_the_mix_s_own_rate() {
764 const FRAMES: usize = 43;
765 const PER_FRAME: usize = 1024;
766 let to = MixFormat {
767 sample_rate: 48_000,
768 channels: 1,
769 };
770 let mut input = InputBuffer {
771 id: 1,
772 resampler: None,
773 samples: VecDeque::new(),
774 eos: false,
775 };
776
777 for _ in 0..FRAMES {
778 input
779 .push(&constant_frame(0.5, PER_FRAME, 44_100), to)
780 .expect("push");
781 }
782
783 let arrived = input.samples.len();
784 let expected = FRAMES * PER_FRAME * 48_000 / 44_100;
785 assert!(
786 arrived * 100 >= expected * 99,
787 "a second of 44.1kHz sound is still a second of mix: \
788 {arrived} samples arrived, {expected} expected"
789 );
790 }
791
792 struct RecordingSink {
793 pp_log: PpLog,
794 seen: Arc<StdMutex<Vec<f32>>>,
795 }
796
797 impl Element for RecordingSink {
798 fn name(&self) -> Arc<str> {
799 "recorder".into()
800 }
801 fn element_type(&self) -> ElementType {
802 ElementType::Other
803 }
804 fn pp_log(&self) -> &PpLog {
805 &self.pp_log
806 }
807 fn pp_log_mut(&mut self) -> &mut PpLog {
808 &mut self.pp_log
809 }
810 }
811
812 impl Sink for RecordingSink {
813 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
814 if let MediaBuffer::Audio(frame) = buf
815 && frame.samples() > 0
816 {
817 self.seen.lock().unwrap().push(frame.plane::<f32>(0)[0]);
818 }
819 Ok(())
820 }
821 fn control(&mut self, _msg: ControlMsg) -> Result<()> {
822 Ok(())
823 }
824 }
825
826 /// Records the *shape* of every frame rather than a sample of it — what
827 /// a format change is visible in.
828 struct ShapeSink {
829 pp_log: PpLog,
830 seen: Arc<StdMutex<Vec<(u32, u16)>>>,
831 }
832
833 impl Element for ShapeSink {
834 fn name(&self) -> Arc<str> {
835 "shapes".into()
836 }
837 fn element_type(&self) -> ElementType {
838 ElementType::Other
839 }
840 fn pp_log(&self) -> &PpLog {
841 &self.pp_log
842 }
843 fn pp_log_mut(&mut self) -> &mut PpLog {
844 &mut self.pp_log
845 }
846 }
847
848 impl Sink for ShapeSink {
849 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
850 if let MediaBuffer::Audio(frame) = buf
851 && frame.samples() > 0
852 {
853 self.seen
854 .lock()
855 .unwrap()
856 .push((frame.rate(), frame.channel_layout().channels() as u16));
857 }
858 Ok(())
859 }
860 fn control(&mut self, _msg: ControlMsg) -> Result<()> {
861 Ok(())
862 }
863 }
864
865 fn constant_stereo_frame(
866 left: f32,
867 right: f32,
868 samples: usize,
869 rate: u32,
870 ) -> ffmpeg::frame::Audio {
871 let mut frame = ffmpeg::frame::Audio::new(
872 ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
873 samples,
874 ffmpeg::ChannelLayout::default(2),
875 );
876 frame.set_rate(rate);
877 let bytes = frame.data_mut(0);
878 let floats =
879 // SAFETY: `bytes` is this frame's own plane, which FFmpeg aligns well past
880 // 4, and `samples * 2` f32s is what the frame was allocated for.
881 unsafe { std::slice::from_raw_parts_mut(bytes.as_mut_ptr() as *mut f32, samples * 2) };
882 for pair in floats.chunks_mut(2) {
883 pair[0] = left;
884 pair[1] = right;
885 }
886 frame
887 }
888
889 struct StereoRecordingSink {
890 pp_log: PpLog,
891 seen: Arc<StdMutex<Vec<(f32, f32)>>>,
892 }
893
894 impl Element for StereoRecordingSink {
895 fn name(&self) -> Arc<str> {
896 "stereo-recorder".into()
897 }
898 fn element_type(&self) -> ElementType {
899 ElementType::Other
900 }
901 fn pp_log(&self) -> &PpLog {
902 &self.pp_log
903 }
904 fn pp_log_mut(&mut self) -> &mut PpLog {
905 &mut self.pp_log
906 }
907 }
908
909 impl Sink for StereoRecordingSink {
910 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
911 if let MediaBuffer::Audio(frame) = buf
912 && frame.samples() > 0
913 {
914 // Raw bytes, not `plane::<f32>(0)`, for the same reason
915 // `InputBuffer::push` above does: `AudioMixer`'s output is
916 // packed multi-channel, and `plane::<T>()` only ever
917 // returns `samples()` elements regardless of channel
918 // count — reading channel 1 through it would silently
919 // read the wrong offset (still inside channel 0's data),
920 // not the second channel.
921 let samples = frame.samples();
922 let bytes = &frame.data(0)[..samples * 2 * 4];
923 // SAFETY: `bytes` is a prefix of the frame's plane, aligned by FFmpeg and
924 // cut to exactly `samples * 2 * 4` bytes just above.
925 let floats = unsafe {
926 std::slice::from_raw_parts(bytes.as_ptr() as *const f32, samples * 2)
927 };
928 self.seen.lock().unwrap().push((floats[0], floats[1]));
929 }
930 Ok(())
931 }
932 fn control(&mut self, _msg: ControlMsg) -> Result<()> {
933 Ok(())
934 }
935 }
936
937 /// Regression test for the packed-multichannel `InputBuffer::push` bug
938 /// (see the comment there): two stereo inputs, each with distinct,
939 /// asymmetric L/R values, should sum per-channel without the channels
940 /// bleeding into each other or silently dropping to zero. Before the
941 /// fix, `plane::<f32>(0)` under-read the resampled buffer (only
942 /// `samples()` interleaved scalars instead of `samples() * channels`),
943 /// which desynced every input's channel alignment.
944 #[test]
945 fn mixes_stereo_sources_without_channel_corruption() {
946 let (mixer, handle) = AudioMixer::new(
947 "mixer",
948 AudioMixerOptions {
949 sample_rate: 48000,
950 channels: 2,
951 },
952 );
953 let seen = Arc::new(StdMutex::new(Vec::new()));
954 let sink = StereoRecordingSink {
955 seen: seen.clone(),
956 pp_log: element_pp_log(ElementType::Other, "stereo-recorder", None),
957 };
958
959 let pipeline = Pipeline::new("mixer-stereo-test", mixer, |source, ctx| {
960 let branch = ctx.branch().to(Box::new(sink))?;
961 ctx.attach(source, 0, branch)?;
962 Ok(())
963 })
964 .expect("test pipeline wiring must succeed");
965 pipeline.run().unwrap();
966
967 let mut input_a = handle.add_source("a").expect("mixer still alive");
968 let mut input_b = handle.add_source("b").expect("mixer still alive");
969
970 let stop = Arc::new(AtomicBool::new(false));
971 let feeder_stop = stop.clone();
972 let feeder = std::thread::spawn(move || {
973 while !feeder_stop.load(Ordering::Relaxed) {
974 let _ = input_a.consume(MediaBuffer::Audio(Arc::new(constant_stereo_frame(
975 0.2, -0.1, 480, 48000,
976 ))));
977 let _ = input_b.consume(MediaBuffer::Audio(Arc::new(constant_stereo_frame(
978 0.1, -0.2, 480, 48000,
979 ))));
980 std::thread::sleep(Duration::from_millis(10));
981 }
982 });
983
984 std::thread::sleep(Duration::from_millis(300));
985 stop.store(true, Ordering::Relaxed);
986 feeder.join().unwrap();
987 pipeline.stop();
988 pipeline.bus().log_events();
989
990 let seen = seen.lock().unwrap();
991 assert!(
992 seen.len() > 5,
993 "expected several mixed frames, got {seen:?}"
994 );
995 let steady = &seen[3..seen.len() - 2];
996 for &(left, right) in steady {
997 assert!(
998 (left - 0.3).abs() < 0.01,
999 "expected left channel ~0.3, got {left} in {seen:?}"
1000 );
1001 assert!(
1002 (right - -0.3).abs() < 0.01,
1003 "expected right channel ~-0.3, got {right} in {seen:?}"
1004 );
1005 }
1006 }
1007
1008 /// Two inputs, each pushing a constant `0.6` from their own thread
1009 /// (standing in for two independent capture pipelines), should sum to
1010 /// `1.2` and get hard-clipped to `1.0` — verifies resampling-on-first-
1011 /// frame, cross-thread `consume`, summation, and clipping all work
1012 /// together, not just in isolation.
1013 #[test]
1014 fn mixes_two_sources_and_hard_clips() {
1015 let (mixer, handle) = AudioMixer::new(
1016 "mixer",
1017 AudioMixerOptions {
1018 sample_rate: 48000,
1019 channels: 1,
1020 },
1021 );
1022 let seen = Arc::new(StdMutex::new(Vec::new()));
1023 let sink = RecordingSink {
1024 seen: seen.clone(),
1025 pp_log: element_pp_log(ElementType::Other, "recorder", None),
1026 };
1027
1028 let pipeline = Pipeline::new("mixer-test", mixer, |source, ctx| {
1029 let branch = ctx.branch().to(Box::new(sink))?;
1030 ctx.attach(source, 0, branch)?;
1031 Ok(())
1032 })
1033 .expect("test pipeline wiring must succeed");
1034 pipeline.run().unwrap();
1035
1036 let mut input_a = handle.add_source("a").expect("mixer still alive");
1037 let mut input_b = handle.add_source("b").expect("mixer still alive");
1038 assert_eq!(handle.source_count(), 2);
1039
1040 let stop = Arc::new(AtomicBool::new(false));
1041 let feeder_stop = stop.clone();
1042 let feeder = std::thread::spawn(move || {
1043 while !feeder_stop.load(Ordering::Relaxed) {
1044 let _ = input_a.consume(MediaBuffer::Audio(Arc::new(constant_frame(
1045 0.6, 480, 48000,
1046 ))));
1047 let _ = input_b.consume(MediaBuffer::Audio(Arc::new(constant_frame(
1048 0.6, 480, 48000,
1049 ))));
1050 std::thread::sleep(Duration::from_millis(10));
1051 }
1052 });
1053
1054 std::thread::sleep(Duration::from_millis(300));
1055 stop.store(true, Ordering::Relaxed);
1056 feeder.join().unwrap();
1057 pipeline.stop();
1058 pipeline.bus().log_events();
1059
1060 let seen = seen.lock().unwrap();
1061 assert!(
1062 seen.len() > 5,
1063 "expected several mixed frames, got {seen:?}"
1064 );
1065 // Skip the first few ticks (the feeder thread may not have caught
1066 // up yet) and the last couple (ticks after the feeder stopped but
1067 // before `pipeline.stop()` landed correctly drain to silence) —
1068 // check the steady state in between is clipped to 1.0.
1069 let steady = &seen[3..seen.len() - 2];
1070 for &value in steady {
1071 assert!(
1072 (value - 1.0).abs() < 0.01,
1073 "expected hard-clipped ~1.0, got {value} in {seen:?}"
1074 );
1075 }
1076 }
1077
1078 #[test]
1079 fn removed_source_stops_contributing() {
1080 let (mixer, handle) = AudioMixer::new(
1081 "mixer",
1082 AudioMixerOptions {
1083 sample_rate: 48000,
1084 channels: 1,
1085 },
1086 );
1087 let seen = Arc::new(StdMutex::new(Vec::new()));
1088 let sink = RecordingSink {
1089 seen: seen.clone(),
1090 pp_log: element_pp_log(ElementType::Other, "recorder", None),
1091 };
1092 let pipeline = Pipeline::new("mixer-test-2", mixer, |source, ctx| {
1093 let branch = ctx.branch().to(Box::new(sink))?;
1094 ctx.attach(source, 0, branch)?;
1095 Ok(())
1096 })
1097 .expect("test pipeline wiring must succeed");
1098 pipeline.run().unwrap();
1099
1100 let mut input_a = handle.add_source("a").unwrap();
1101 input_a
1102 .consume(MediaBuffer::Audio(Arc::new(constant_frame(
1103 0.5, 480, 48000,
1104 ))))
1105 .unwrap();
1106 std::thread::sleep(Duration::from_millis(100));
1107 handle.remove_source("a");
1108 assert_eq!(handle.source_count(), 0);
1109 seen.lock().unwrap().clear();
1110
1111 std::thread::sleep(Duration::from_millis(100));
1112 pipeline.stop();
1113 pipeline.bus().log_events();
1114
1115 assert!(
1116 seen.lock().unwrap().iter().all(|&v| v == 0.0),
1117 "removed source must not keep contributing: {:?}",
1118 *seen.lock().unwrap()
1119 );
1120 }
1121
1122 /// The mixer must keep emitting with nothing feeding it at all.
1123 ///
1124 /// It is what a recording attached later is made of, and what the
1125 /// application's own mixer dock is built on: a file whose audio track
1126 /// stopped because the last source was removed would be a worse answer
1127 /// than one carrying silence. The compositor's own version of this — an
1128 /// empty Scene still composites black — had to be fixed once, so this
1129 /// says so for the mixer before anybody has to find out.
1130 ///
1131 /// `removed_source_stops_contributing` above cannot see it: it asserts
1132 /// every sample that arrives is zero, which an empty vector satisfies.
1133 #[test]
1134 fn a_mixer_with_no_sources_still_emits_silence() {
1135 let (mixer, handle) = AudioMixer::new(
1136 "mixer",
1137 AudioMixerOptions {
1138 sample_rate: 48000,
1139 channels: 1,
1140 },
1141 );
1142 let seen = Arc::new(StdMutex::new(Vec::new()));
1143 let sink = RecordingSink {
1144 seen: seen.clone(),
1145 pp_log: element_pp_log(ElementType::Other, "recorder", None),
1146 };
1147 let pipeline = Pipeline::new("mixer-test-idle", mixer, |source, ctx| {
1148 let branch = ctx.branch().to(Box::new(sink))?;
1149 ctx.attach(source, 0, branch)?;
1150 Ok(())
1151 })
1152 .expect("test pipeline wiring must succeed");
1153 pipeline.run().unwrap();
1154
1155 // Nothing is ever added: no `add_source`, no buffer, no removal.
1156 std::thread::sleep(Duration::from_millis(200));
1157 pipeline.stop();
1158 pipeline.bus().log_events();
1159
1160 let seen = seen.lock().unwrap();
1161 assert_eq!(handle.source_count(), 0, "this test adds no sources");
1162 assert!(
1163 !seen.is_empty(),
1164 "the mixer must go on emitting with nothing feeding it"
1165 );
1166 assert!(
1167 seen.iter().all(|&sample| sample == 0.0),
1168 "what it emits with no sources must be silence: {seen:?}"
1169 );
1170 }
1171
1172 /// Regression test: a capture pipeline ending via `Stop` — the only
1173 /// shutdown signal a live source like `WasapiCaptureSource` ever sends,
1174 /// since it never reaches `Eos` on its own — used to leave a stale
1175 /// entry in the mixer's input map forever, because only `Eos` cleared
1176 /// it. `Sink::control` is what a `Queue`/`Pipeline` actually calls on
1177 /// `Stop` (mirrored by hand here, since this input isn't wired into a
1178 /// real second `Pipeline` in this test), not `consume`.
1179 #[test]
1180 fn stopped_source_is_removed_without_an_explicit_remove_source_call() {
1181 let (mixer, handle) = AudioMixer::new(
1182 "mixer",
1183 AudioMixerOptions {
1184 sample_rate: 48000,
1185 channels: 1,
1186 },
1187 );
1188 let seen = Arc::new(StdMutex::new(Vec::new()));
1189 let sink = RecordingSink {
1190 seen: seen.clone(),
1191 pp_log: element_pp_log(ElementType::Other, "recorder", None),
1192 };
1193 let pipeline = Pipeline::new("mixer-test-3", mixer, |source, ctx| {
1194 let branch = ctx.branch().to(Box::new(sink))?;
1195 ctx.attach(source, 0, branch)?;
1196 Ok(())
1197 })
1198 .expect("test pipeline wiring must succeed");
1199 pipeline.run().unwrap();
1200
1201 let mut input_a = handle.add_source("a").unwrap();
1202 input_a
1203 .consume(MediaBuffer::Audio(Arc::new(constant_frame(
1204 0.5, 480, 48000,
1205 ))))
1206 .unwrap();
1207 assert_eq!(handle.source_count(), 1);
1208
1209 // What a `Queue`/`Pipeline` actually calls on this input's own
1210 // `Sink` when its upstream capture pipeline is stopped — never
1211 // `consume(Eos)`, since `WasapiCaptureSource` doesn't send one.
1212 input_a.control(ControlMsg::Stop).unwrap();
1213
1214 assert_eq!(
1215 handle.source_count(),
1216 0,
1217 "Stop should remove the input immediately, same as remove_source"
1218 );
1219
1220 pipeline.stop();
1221 pipeline.bus().log_events();
1222 }
1223
1224 /// Re-registering a name replaces its input buffer, but callers may
1225 /// still hold the sink returned for the old registration. Every late
1226 /// operation through that stale sink must be inert rather than being
1227 /// redirected to (or deleting) the replacement merely because the map
1228 /// key is the same.
1229 #[test]
1230 fn replacing_an_input_by_name_invalidates_the_stale_sink() {
1231 let (_mixer, handle) = AudioMixer::new(
1232 "mixer",
1233 AudioMixerOptions {
1234 sample_rate: 48000,
1235 channels: 1,
1236 },
1237 );
1238 let mut stale = handle.add_source("mic").expect("mixer still alive");
1239 let mut current = handle.add_source("mic").expect("mixer still alive");
1240 assert_eq!(handle.source_count(), 1);
1241
1242 stale
1243 .consume(MediaBuffer::Audio(Arc::new(constant_frame(
1244 0.75, 480, 48000,
1245 ))))
1246 .unwrap();
1247 stale.consume(MediaBuffer::Eos).unwrap();
1248 stale.control(ControlMsg::Stop).unwrap();
1249
1250 assert_eq!(
1251 handle.source_count(),
1252 1,
1253 "a stale sink's Stop must not remove its replacement"
1254 );
1255 let shared = handle.shared.upgrade().expect("mixer still alive");
1256 {
1257 let inputs = shared.inputs.lock().unwrap();
1258 let input = inputs.get("mic").expect("replacement remains registered");
1259 assert!(
1260 input.resampler.is_none() && input.samples.is_empty(),
1261 "stale audio must not enter the replacement buffer"
1262 );
1263 assert!(!input.eos, "stale Eos must not mark the replacement ended");
1264 }
1265
1266 // The current sink still owns the registration and therefore
1267 // remains fully functional.
1268 current
1269 .consume(MediaBuffer::Audio(Arc::new(constant_frame(
1270 0.25, 480, 48000,
1271 ))))
1272 .unwrap();
1273 current.consume(MediaBuffer::Eos).unwrap();
1274 {
1275 let inputs = shared.inputs.lock().unwrap();
1276 let input = inputs.get("mic").expect("replacement remains registered");
1277 assert!(input.resampler.is_some(), "current audio was not accepted");
1278 assert!(input.eos, "current Eos was not accepted");
1279 }
1280
1281 current.control(ControlMsg::Stop).unwrap();
1282 assert_eq!(handle.source_count(), 0);
1283 }
1284
1285 /// A misrouted `Packet`/`Video` buffer used to be silently logged and
1286 /// dropped — no `BusEvent::Error`, no way for a misconfigured pipeline
1287 /// to ever find out. Matches the typed-error pattern every other
1288 /// `Sink` in this codebase already uses for a wrong `MediaBuffer`
1289 /// variant (e.g. `FileMuxerStreamSink`).
1290 #[test]
1291 fn rejects_buffers_that_are_neither_audio_nor_eos() {
1292 let (mixer, handle) = AudioMixer::new(
1293 "mixer",
1294 AudioMixerOptions {
1295 sample_rate: 48000,
1296 channels: 2,
1297 },
1298 );
1299 let mut input = handle.add_source("a").expect("mixer still alive");
1300
1301 let error = input
1302 .consume(MediaBuffer::Packet(Arc::new(ffmpeg::Packet::empty())))
1303 .expect_err("a Packet buffer must be rejected, not silently dropped");
1304 assert!(
1305 matches!(
1306 error,
1307 crate::error::Error::AudioMixerError(AudioMixerError::UnsupportedBuffer("Packet"))
1308 ),
1309 "unexpected error: {error:?}"
1310 );
1311
1312 drop(mixer);
1313 }
1314
1315 /// A rate change reaches the output frames, and the mix does not stop
1316 /// while the new rate "catches up" with the samples already emitted.
1317 ///
1318 /// That stall is the whole reason `MixAnchor` exists: the deficit is
1319 /// `elapsed × sample_rate` against a running count, and measured straight
1320 /// across a change to a lower rate it goes negative for as long as it
1321 /// takes the new rate to reach the old count — a minute of silence for a
1322 /// setting somebody just applied.
1323 #[test]
1324 fn changing_the_mix_format_keeps_the_mix_going() {
1325 let (mixer, handle) = AudioMixer::new(
1326 "mixer",
1327 AudioMixerOptions {
1328 sample_rate: 48000,
1329 channels: 2,
1330 },
1331 );
1332 let seen: Arc<StdMutex<Vec<(u32, u16)>>> = Arc::new(StdMutex::new(Vec::new()));
1333 let sink = ShapeSink {
1334 seen: seen.clone(),
1335 pp_log: element_pp_log(ElementType::Other, "shapes", None),
1336 };
1337 let pipeline = Pipeline::new("mixer-test-rate", mixer, |source, ctx| {
1338 let branch = ctx.branch().to(Box::new(sink))?;
1339 ctx.attach(source, 0, branch)?;
1340 Ok(())
1341 })
1342 .expect("test pipeline wiring must succeed");
1343 pipeline.run().unwrap();
1344
1345 std::thread::sleep(Duration::from_millis(150));
1346 assert_eq!(
1347 handle.mix_format(),
1348 Some(MixFormat {
1349 sample_rate: 48000,
1350 channels: 2
1351 })
1352 );
1353 // Down, which is the direction that stalls without the anchor, and
1354 // to mono so the frame's own shape has to move as well.
1355 assert!(handle.set_mix_format(MixFormat {
1356 sample_rate: 16000,
1357 channels: 1,
1358 }));
1359 let before = seen.lock().unwrap().len();
1360 std::thread::sleep(Duration::from_millis(250));
1361 pipeline.stop();
1362 pipeline.bus().log_events();
1363
1364 let seen = seen.lock().unwrap();
1365 assert!(
1366 seen.len() > before,
1367 "the mix stopped after the format changed: {before} frames before, {} after",
1368 seen.len()
1369 );
1370 assert_eq!(
1371 seen.last().copied(),
1372 Some((16000, 1)),
1373 "the new rate and channel count have to reach the output"
1374 );
1375 }
1376
1377 /// A format nothing could be resampled to is refused, and refusing leaves
1378 /// the running one alone rather than a mixer summing into nothing.
1379 #[test]
1380 fn an_impossible_mix_format_is_refused_and_changes_nothing() {
1381 let (_mixer, handle) = AudioMixer::new(
1382 "mixer",
1383 AudioMixerOptions {
1384 sample_rate: 48000,
1385 channels: 2,
1386 },
1387 );
1388 for refused in [
1389 MixFormat {
1390 sample_rate: 0,
1391 channels: 2,
1392 },
1393 MixFormat {
1394 sample_rate: 48000,
1395 channels: 0,
1396 },
1397 ] {
1398 assert!(!handle.set_mix_format(refused), "{refused:?} was accepted");
1399 assert_eq!(
1400 handle.mix_format(),
1401 Some(MixFormat {
1402 sample_rate: 48000,
1403 channels: 2
1404 })
1405 );
1406 }
1407 }
1408
1409 /// And the handle answers rather than taking effect once the mixer is
1410 /// gone, like every other method on it.
1411 #[test]
1412 fn the_mix_format_setter_reports_a_mixer_that_is_gone() {
1413 let (mixer, handle) = AudioMixer::new(
1414 "mixer",
1415 AudioMixerOptions {
1416 sample_rate: 48000,
1417 channels: 2,
1418 },
1419 );
1420 drop(mixer);
1421
1422 assert!(!handle.set_mix_format(MixFormat {
1423 sample_rate: 16000,
1424 channels: 1,
1425 }));
1426 assert_eq!(handle.mix_format(), None);
1427 }
1428
1429 /// A file whose sound is not at the mix's rate, played into the mix.
1430 ///
1431 /// The ordinary case, not a corner one: most media is 44.1kHz and most
1432 /// capture devices are 48kHz, so an application playing a file alongside
1433 /// a device has a resampler in the path whether it asked for one or not.
1434 ///
1435 /// What broke there was invisible to a test of the parts. Every element
1436 /// reported success, the mix kept coming out at its own rate, and the
1437 /// resampler was quietly handing back 8% less audio than went in — which
1438 /// this mixer, whose contract is to keep producing on a wall clock, made
1439 /// up with silence. So these measure the mix itself.
1440 mod against_a_file {
1441 use std::sync::Mutex as StdMutex;
1442
1443 use super::*;
1444 use crate::{
1445 elements::{AppSink, FileDemuxer, Pacer, SwDecoder, TestAudioOptions, TestAudioSource},
1446 pipeline::Pipeline,
1447 test_support,
1448 };
1449
1450 const MIX_RATE: u32 = 48_000;
1451 const FILE_RATE: u32 = 44_100;
1452
1453 /// How long the mix is watched. Long enough for a shortfall of a few
1454 /// percent per tick to be unmistakable, short enough to stay a test.
1455 const WATCH: Duration = Duration::from_secs(3);
1456
1457 /// How much of a mix carrying a continuous tone may be silence, in
1458 /// parts per thousand.
1459 ///
1460 /// The tone never lands exactly on 0.0 for a run of samples, so what
1461 /// this counts is the mixer's own padding: what it puts in when an
1462 /// input is short for a tick. A resampler handing back less than it
1463 /// was given pads *every* tick — 80‰ at the ratio used here. What is
1464 /// left when nothing is wrong is the occasional scheduling hiccup, a
1465 /// sub-millisecond gap every few seconds, measured at well under one
1466 /// part in a thousand.
1467 const SILENT_PER_MILLE: usize = 10;
1468
1469 /// Runs shorter than this are a waveform touching zero rather than a
1470 /// gap in it.
1471 const HOLE: usize = 4;
1472
1473 #[derive(Default, Clone)]
1474 struct MixShape {
1475 samples: usize,
1476 silent: usize,
1477 holes: usize,
1478 longest_hole: usize,
1479 current_run: usize,
1480 }
1481
1482 impl MixShape {
1483 fn absorb(&mut self, frame: &ffmpeg::frame::Audio) {
1484 let bytes = frame.samples() * frame.channels() as usize * 4;
1485 let data = &frame.data(0)[..bytes.min(frame.data(0).len())];
1486 let (samples, _) = data.as_chunks::<4>();
1487 for chunk in samples {
1488 let sample = f32::from_le_bytes(*chunk);
1489 self.samples += 1;
1490 if sample == 0.0 {
1491 self.silent += 1;
1492 self.current_run += 1;
1493 } else {
1494 if self.current_run >= HOLE {
1495 self.holes += 1;
1496 self.longest_hole = self.longest_hole.max(self.current_run);
1497 }
1498 self.current_run = 0;
1499 }
1500 }
1501 }
1502
1503 fn silent_per_mille(&self) -> usize {
1504 if self.samples == 0 {
1505 return 0;
1506 }
1507 self.silent * 1000 / self.samples
1508 }
1509
1510 fn report(&self) -> String {
1511 format!(
1512 "{} of {} samples silent ({}per mille), in {} hole(s), longest {}",
1513 self.silent,
1514 self.samples,
1515 self.silent_per_mille(),
1516 self.holes,
1517 self.longest_hole
1518 )
1519 }
1520 }
1521
1522 /// A mixer running on its own pipeline, with everything it emits
1523 /// measured.
1524 fn watched_mix(rate: u32) -> (Arc<Pipeline>, MixerHandle, Arc<StdMutex<MixShape>>) {
1525 let shape = Arc::new(StdMutex::new(MixShape::default()));
1526 let listener = AppSink::new("mix-listener", {
1527 let shape = Arc::clone(&shape);
1528 move |buffer| {
1529 if let MediaBuffer::Audio(frame) = &buffer {
1530 shape.lock().expect("mix shape poisoned").absorb(frame);
1531 }
1532 Ok(())
1533 }
1534 });
1535 let (mixer, handle) = AudioMixer::new(
1536 "mixer",
1537 AudioMixerOptions {
1538 sample_rate: rate,
1539 channels: 2,
1540 },
1541 );
1542 let pipeline = Pipeline::new("mix", mixer, move |source, context| {
1543 let branch = context.branch().to(Box::new(listener))?;
1544 context.attach(source, 0, branch)?;
1545 Ok(())
1546 })
1547 .expect("wire the mix");
1548 pipeline.run().expect("run the mix");
1549 (pipeline, handle, shape)
1550 }
1551
1552 /// The fixture's sound, decoded, paced and pushed into the mix on a
1553 /// pipeline of its own — which is how an application does it, the
1554 /// mixer on its own thread with sources coming and going around it.
1555 ///
1556 /// The `Pacer` is not decoration. Without one the demuxer reads the
1557 /// file as fast as it decodes and the mixer's input never runs dry,
1558 /// so an input handing back less audio than it was given only fills a
1559 /// queue more slowly and nothing downstream can tell. Paced, the file
1560 /// arrives at the rate it claims and a shortfall is a tick the mixer
1561 /// has to finish with silence — which is the whole of what this
1562 /// measures.
1563 fn play_into(path: &str, mixer: &MixerHandle) -> Arc<Pipeline> {
1564 let (demuxer, streams) = FileDemuxer::open("fixture", path).expect("open the fixture");
1565 let audio = streams
1566 .iter()
1567 .find(|stream| stream.kind == ffmpeg::media::Type::Audio)
1568 .expect("the fixture has sound")
1569 .index;
1570 let parameters = demuxer
1571 .stream_parameters(audio)
1572 .expect("the audio stream describes itself");
1573 let time_base = demuxer
1574 .stream_time_base(audio)
1575 .expect("the audio stream has a unit");
1576 let decoder = SwDecoder::new("fixture-decoder", parameters).expect("open the decoder");
1577 let sink = mixer
1578 .add_source("fixture".to_owned())
1579 .expect("the mixer is running");
1580
1581 let pipeline = Pipeline::new("playback", demuxer, move |source, context| {
1582 let branch = context
1583 .branch()
1584 .pipe(decoder)
1585 .queue("audio", 32)
1586 .pipe(Pacer::new("fixture-pacer", time_base)?)
1587 .to(sink)?;
1588 context.attach(source, audio, branch)?;
1589 Ok(())
1590 })
1591 .expect("wire the playback pipeline");
1592 pipeline.run().expect("play the fixture");
1593 pipeline
1594 }
1595
1596 /// The fixture has to be what these tests assume, or what they measure
1597 /// is something else. Cheap, and it fails at the generator rather than
1598 /// three assertions later.
1599 #[test]
1600 fn the_fixture_carries_sound_at_a_rate_the_mix_does_not_run_at() {
1601 let fixture = test_support::synthesize("mixed-rate", 2.0, FILE_RATE);
1602 let input =
1603 ffmpeg::format::input(&fixture.path).expect("the fixture is a readable container");
1604 let audio = input
1605 .streams()
1606 .find(|stream| stream.parameters().medium() == ffmpeg::media::Type::Audio)
1607 .expect("the fixture has an audio stream");
1608 let decoder = ffmpeg::codec::context::Context::from_parameters(audio.parameters())
1609 .expect("the audio stream describes itself")
1610 .decoder()
1611 .audio()
1612 .expect("it is audio");
1613
1614 assert_eq!(
1615 decoder.rate(),
1616 fixture.audio_rate,
1617 "the fixture must carry the rate it was asked for"
1618 );
1619 assert_ne!(FILE_RATE, MIX_RATE, "otherwise nothing is resampled");
1620 assert_eq!(decoder.channels(), fixture.channels);
1621 assert!(
1622 input
1623 .streams()
1624 .any(|stream| stream.parameters().medium() == ffmpeg::media::Type::Video),
1625 "a Source that occupies a rectangle needs its picture too"
1626 );
1627 }
1628
1629 /// A 44.1kHz file into a 48kHz mix has to fill every tick of it.
1630 ///
1631 /// Not "arrive" as in the mix keeps producing — it does that with or
1632 /// without an input, filling whatever an input is short by with
1633 /// silence, which is exactly what made this so quiet. What is
1634 /// measured is the silence.
1635 #[test]
1636 fn a_file_below_the_mix_rate_fills_every_tick_of_it() {
1637 let fixture = test_support::synthesize("mixed-rate-mix", 6.0, FILE_RATE);
1638 let (mix, handle, shape) = watched_mix(MIX_RATE);
1639 let playback = play_into(&fixture.path.to_string_lossy(), &handle);
1640
1641 // What a mix emits before its input has said anything is silence,
1642 // correctly, so the measurement starts after the first samples.
1643 thread::sleep(Duration::from_millis(500));
1644 *shape.lock().expect("mix shape poisoned") = MixShape::default();
1645 thread::sleep(WATCH);
1646 let measured = shape.lock().expect("mix shape poisoned").clone();
1647 playback.stop();
1648 mix.stop();
1649
1650 assert!(
1651 measured.samples > 0,
1652 "the mix produced nothing to look at in {WATCH:?}"
1653 );
1654 assert!(
1655 measured.silent_per_mille() <= SILENT_PER_MILLE,
1656 "a {FILE_RATE}Hz file did not fill a {MIX_RATE}Hz mix: {}",
1657 measured.report()
1658 );
1659 }
1660
1661 /// The same mixer fed at its own rate, so a failure above is read as
1662 /// what it is. Nothing is resampled here, and a hole would mean
1663 /// something other than the rate conversion.
1664 #[test]
1665 fn a_source_at_the_mix_rate_fills_it_too() {
1666 let (mix, handle, shape) = watched_mix(MIX_RATE);
1667 let tone = TestAudioSource::new(
1668 "tone",
1669 TestAudioOptions {
1670 sample_rate: MIX_RATE,
1671 channels: 2,
1672 frequency: 440.0,
1673 },
1674 );
1675 let sink = handle
1676 .add_source("tone".to_owned())
1677 .expect("the mixer is running");
1678 let source = Pipeline::new("tone", tone, move |source, context| {
1679 let branch = context.branch().to(sink)?;
1680 context.attach(source, 0, branch)?;
1681 Ok(())
1682 })
1683 .expect("wire the tone");
1684 source.run().expect("run the tone");
1685
1686 thread::sleep(Duration::from_millis(500));
1687 *shape.lock().expect("mix shape poisoned") = MixShape::default();
1688 thread::sleep(WATCH);
1689 let measured = shape.lock().expect("mix shape poisoned").clone();
1690 source.stop();
1691 mix.stop();
1692
1693 assert!(
1694 measured.silent_per_mille() <= SILENT_PER_MILLE,
1695 "a source at the mix's own rate did not fill it: {}",
1696 measured.report()
1697 );
1698 }
1699 }
1700}