Back to Naudio

Mix the Microphone and System Audio into One Stream

Docs/MixMicrophoneAndSystemAudio.md

3.0.110.3 KB
Original Source

Mix the Microphone and System Audio into One Stream

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.

Finding a common format

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:

  • System audio — captured with WasapiRecorder and WithLoopbackCapture() (or the legacy WasapiLoopbackCapture) — is delivered as IEEE float at the render device's mix format, commonly 48 kHz, stereo.
  • The microphone — captured with 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:

  1. Buffer the incoming bytes in a BufferedWaveProvider (the capture callbacks and the mixer read on different threads).
  2. Convert to ISampleProvider with .ToSampleProvider() — this normalises 8/16/24/32‑bit PCM and IEEE float to 32‑bit float for you.
  3. Make the channel count match (e.g. MonoToStereoSampleProvider for a mono mic feeding a stereo mix).
  4. Make the sample rate match with 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 — CaptureMixerInput compares the two and adds WdlResamplingSampleProvider only 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's WasapiRecorderBuilder.WithFormat(...): WASAPI's own engine converts the slower ones during capture, and CaptureMixerInput again 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.)

Use the packaged helper (NAudio.Extras)

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:

c#
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();

AddSamples takes a ReadOnlySpan<byte>, so it works just as well with a legacy IWaveIn device: waveIn.DataAvailable += (s, a) => input.AddSamples(a.Buffer.AsSpan(0, a.BytesRecorded));.

Writing the mix to a WAV file

RealtimeCaptureMixer.Read returns only as much audio as the wall clock says should exist by now, so a background pump stays real-time:

c#
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 plain MixingSampleProvider? A mixer with ReadFully = true never 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.Read throttles 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 a ReadFully mixer.

Getting the mixed bytes instead of a file

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:

c#
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

Keeping the sources aligned

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:

  • Loopback gaps. WASAPI loopback only raises 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.
  • Clock-rate drift. The microphone and the soundcard run off independent clocks, so one delivers marginally more or fewer frames per second than the other. Because output is clocked to the wall clock, a slightly slow source is padded and a slightly fast one is drained, keeping both locked to real time.

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.DataAvailable also hands you each packet's qpcPosition and devicePosition, 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.

Things to watch out for

  • Clipping. Summing two loud sources can exceed ±1.0f. Reduce the inputs before mixing — MonoToStereoSampleProvider exposes LeftVolume/RightVolume, or insert a VolumeSampleProvider per source.
  • Loopback silence. WASAPI loopback capture only raises 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.
  • Clock drift. The microphone and the soundcard are driven by independent clocks, but the wall-clock-paced output keeps both locked to real time (see above). This isn't sample-accurate; if you need tighter long-run A/V sync you'd add per-source adaptive resampling driven by the measured QPC-vs-samples error.
  • Disposal order. Stop both captures, let the pump loop finish, then dispose the recorders and the writer. WasapiRecorder also implements IAsyncDisposable, so prefer await recorder.DisposeAsync() (or await using) off a UI thread.
  • Legacy capture devices. 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));.

Mixing vs. separate channels

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.