Docs/MixMicrophoneAndSystemAudio.md
In this tutorial we will show how to capture audio from multiple devices (e.g. recording two microphones, or mixing a microphone with the system loopback audio) and mix them into a single stream. For example, if you want to produce a single WAV file, or to feed the mixed audio into a video muxer.
MixingSampleProvider sums its inputs sample‑for‑sample. For that to be meaningful, every input must have the same wave format: the same sample rate, the same channel count, and 32‑bit IEEE float samples. Two capture devices almost never agree on this:
WasapiRecorder and WithLoopbackCapture() (or the legacy WasapiLoopbackCapture) — is delivered as IEEE float at the render device's mix format, commonly 48 kHz, stereo.WasapiRecorder (or a legacy WaveInEvent) — has the capture device's mix format, often a different sample rate and channel count (e.g. 44.1 kHz, mono). A legacy WaveInEvent mic is usually 16‑bit PCM as well.So before mixing you have to bring both sources to a common format. The pipeline for each source is:
BufferedWaveProvider (the capture callbacks and the mixer read on different threads).ISampleProvider with .ToSampleProvider() — this normalises 8/16/24/32‑bit PCM and IEEE float to 32‑bit float for you.MonoToStereoSampleProvider for a mono mic feeding a stereo mix).WdlResamplingSampleProvider (cross‑platform, no Media Foundation dependency).Then add both adapted sources to a single MixingSampleProvider and read from it.
Skip the resampler when you can. Step 4 is only inserted when a source's rate differs from the mixer's —
CaptureMixerInputcompares the two and addsWdlResamplingSampleProvideronly if needed. So if all your devices already run at the same rate, capture at that rate and no resampling happens at all. When they differ, pick the highest device rate as the mixer rate and pass it to each source'sWasapiRecorderBuilder.WithFormat(...): WASAPI's own engine converts the slower ones during capture, andCaptureMixerInputagain adds no resampler. (The "Mixing Capture" demo does exactly this — it reads each device's mix format, mixes at the highest rate, and only requests conversion for sources below it.)
You don't have to write any of this yourself. NAudio.Extras ships CaptureMixerInput and RealtimeCaptureMixer, which implement exactly this pipeline — including timestamp-based alignment so independently-clocked sources don't drift apart (see Keeping the sources aligned). The runnable "Mixing Capture" panel in NAudioDemo wires them to two or three WASAPI sources at once (microphone and/or loopback), with a level meter per source and a maximum recording length.
CaptureMixerInput adapts one source to the common format; RealtimeCaptureMixer bundles the inputs, their shared timeline, and a wall-clock-paced output:
using NAudio.Extras;
using NAudio.Wave;
var mixer = new RealtimeCaptureMixer(WaveFormat.CreateIeeeFloatWaveFormat(48000, 2));
// system audio (loopback) and the microphone, each captured with WasapiRecorder
var systemRecorder = new WasapiRecorderBuilder().WithLoopbackCapture().WithPollingSync().Build();
var micRecorder = new WasapiRecorderBuilder().Build();
// add each source in its native format; the input resamples/rechannels to the mixer format
var systemInput = mixer.AddInput(systemRecorder.WaveFormat);
var micInput = mixer.AddInput(micRecorder.WaveFormat);
// feed each recorder's zero-copy packets into its input (append in arrival order)
systemRecorder.DataAvailable += (data, flags, dev, qpc) => systemInput.AddSamples(data);
micRecorder.DataAvailable += (data, flags, dev, qpc) => micInput.AddSamples(data);
mixer.Start();
systemRecorder.StartRecording();
micRecorder.StartRecording();
AddSamplestakes aReadOnlySpan<byte>, so it works just as well with a legacyIWaveIndevice:waveIn.DataAvailable += (s, a) => input.AddSamples(a.Buffer.AsSpan(0, a.BytesRecorded));.
RealtimeCaptureMixer.Read returns only as much audio as the wall clock says should exist by now, so a background pump stays real-time:
var writer = new WaveFileWriter("mixed.wav", mixer.WaveFormat);
var buffer = new float[mixer.WaveFormat.SampleRate * mixer.WaveFormat.Channels / 5]; // ~200ms
var stop = false;
var pump = Task.Run(() =>
{
while (!stop)
{
int read = mixer.Read(buffer, 0, buffer.Length);
if (read > 0) writer.WriteSamples(buffer, 0, read);
else Thread.Sleep(5); // caught up with the wall clock (or still in pre-roll) — wait
}
});
// ... record for as long as you want ...
stop = true;
pump.Wait();
systemRecorder.StopRecording();
micRecorder.StopRecording();
systemRecorder.Dispose(); // or 'await DisposeAsync()' off the UI thread
micRecorder.Dispose();
writer.Dispose();
Why the paced
Read, and not a plainMixingSampleProvider? A mixer withReadFully = truenever blocks — it zero-fills any input that has no data yet. Read it flat out and you race ahead of real time, producing a file padded with silence that is longer than the actual recording.RealtimeCaptureMixer.Readthrottles the output to the wall clock so the file length matches elapsed time (which also absorbs the tiny clock-rate differences between devices). If you assemble the mixer by hand, pace the output yourself — don't free-run aReadFullymixer.
If you're feeding a video muxer (e.g. the AVI scenario in #761) you want raw bytes rather than a WAV file. Read paced float samples from the mixer and convert each chunk to 16‑bit PCM:
var floats = new float[mixer.WaveFormat.SampleRate * mixer.WaveFormat.Channels / 10]; // 100ms
var pcm = new byte[floats.Length * 2];
int samples = mixer.Read(floats, 0, floats.Length);
for (int i = 0; i < samples; i++)
{
short s = (short)(Math.Clamp(floats[i], -1f, 1f) * short.MaxValue);
pcm[i * 2] = (byte)(s & 0xFF);
pcm[i * 2 + 1] = (byte)(s >> 8);
}
// hand pcm[0 .. samples * 2] to your encoder / muxer
The heavy lifting is done by pacing the output to the wall clock, not by per-packet correction. RealtimeCaptureMixer.Read hands back only as much audio as real time says should exist, and the mixer zero-fills any input whose buffer is momentarily empty. That single mechanism handles the two things that would otherwise pull sources apart:
DataAvailable while audio is actually playing. While the system is quiet that input's buffer simply drains and the mixer pads silence for it; when playback resumes the buffered audio plays at the right moment.The output is anchored to the first captured sample (not to when you called Start), so a recording begins at the first real audio with only a small pre-roll cushion of latency — a device that is slow to spin up doesn't add a long leading gap or swallow the start.
Why not use the packet timestamps?
WasapiRecorder.DataAvailablealso hands you each packet'sqpcPositionanddevicePosition, and an earlier version of this helper used them to align source start times and back-fill glitches. In practice, some WASAPI shared-mode drivers populate those positions inconsistently — commonly a real value on the first packet and then zero — so acting on them inserted large amounts of spurious silence and made things worse than a plain append. The wall-clock pacing above needs no timestamps and works regardless, so the timestamp path was removed.
For diagnostics, CaptureMixerInput exposes BufferedFrames (and HasReceivedData), and RealtimeCaptureMixer exposes OutputFrames. The demo shows the buffered frames live per source, which is handy for confirming audio is actually flowing and how much latency is buffered.
±1.0f. Reduce the inputs before mixing — MonoToStereoSampleProvider exposes LeftVolume/RightVolume, or insert a VolumeSampleProvider per source.DataAvailable while audio is actually playing, so RealtimeCaptureMixer fills those gaps with silence (the output stays paced to the wall clock) — a loopback source with nothing playing simply contributes silence.WasapiRecorder also implements IAsyncDisposable, so prefer await recorder.DisposeAsync() (or await using) off a UI thread.CaptureMixerInput is device-agnostic: to mix a classic IWaveIn device (WaveInEvent, WasapiLoopbackCapture) add it with mixer.AddInput(waveIn.WaveFormat) and feed it the same way — waveIn.DataAvailable += (s, a) => input.AddSamples(a.Buffer.AsSpan(0, a.BytesRecorded));.If your goal is not to sum the two sources but to keep them separate — for example microphone on the left channel and system audio on the right (#1220) — use MultiplexingSampleProvider (or MultiplexingWaveProvider) instead of MixingSampleProvider. You still bring both sources to a common sample rate first, but you map input channels to output channels rather than adding them together.