Recursive AI

Record audio in C#

Record from the microphone in C# on Windows, Linux and macOS: write a WAV file while recording, show a live input level, and handle missing devices.

To record audio from the microphone in C#, add the RecursiveAI.OneAudio.Devices package, start the default input with Audio.CreateInput(), and drain it with mic.Read(buffer) into a WavEncoder, which writes the WAV file as you go. The same code records through WASAPI on Windows, Core Audio on macOS and iOS, and ALSA on Linux (including PipeWire and PulseAudio desktops), and it is managed .NET code with nothing native to ship. Every sample below was compiled and run against OneAudio 1.2.1 on .NET 10, on Linux with a USB webcam microphone, and the outputs shown are from those runs.

Before you start

TerminalNuGet
dotnet new console -o RecordAudio
cd RecordAudio
dotnet add package RecursiveAI.OneAudio.Devices --version 1.2.1

The device package brings in RecursiveAI.OneAudio, which has the WAV, FLAC and Opus encoders. Both target .NET 10 and are licensed MIT OR Apache-2.0.

Record the microphone to a WAV file

Program.csOneAudio.Devices 1.2.1 · .NET 10
using OneAudio;
using OneAudio.Devices;
 
const int Rate = 48000, Seconds = 3;
 
// Open and start the default input device: 48 kHz mono, converted for you if the microphone runs at another rate.
using AudioInputDevice mic = Audio.CreateInput(new DeviceStreamOptions { SampleRate = Rate, Channels = 1 });
Console.WriteLine($"{mic.BackendName}: recording {Seconds} s from {mic.Device?.Name ?? "no device"}");
 
// Write 16-bit PCM WAV while recording.
using (FileStream file = File.Create("take.wav"))
using (var wav = new WavEncoder(file, new AudioFormat(Rate, 1)))
{
    float[] block = new float[Rate / 10];                 // up to 100 ms per read
    long recorded = 0;
    while (recorded < Rate * Seconds)
    {
        int frames = mic.Read(block);                     // never blocks: 0 means nothing has arrived yet
        if (frames == 0) { Thread.Sleep(10); continue; }
        frames = (int)Math.Min(frames, Rate * Seconds - recorded);
        wav.Write(block.AsSpan(0, frames));
        recorded += frames;
    }
    wav.Finish();
}
 
AudioClip take = Audio.Load("take.wav");
float peak = 0;
foreach (float s in take.Samples.Span) peak = Math.Max(peak, Math.Abs(s));
Console.WriteLine($"take.wav: {take.Frames} frames at {take.Format.SampleRate} Hz, {take.Format.NativeType}, " +
                  $"peak {20 * Math.Log10(peak):F1} dBFS, overruns {mic.Counters.Overruns}");
OutputConsole · Linux
alsa: recording 3 s from default
take.wav: 144000 frames at 48000 Hz, PcmSigned16, peak -31.6 dBFS, overruns 0
  • Audio.CreateInput opens and starts the default input. You choose the rate and channel count you want; if the microphone runs at something else (this webcam runs at 32 kHz), the system’s audio layer converts, and you get 48 kHz mono floats.
  • Captured audio goes into a ring buffer of one second by default (CaptureBufferMilliseconds). Read copies whatever has arrived and never blocks, returning 0 when nothing is waiting, so the loop sleeps briefly and tries again. If nobody reads for longer than the buffer holds, new audio is dropped and Overruns counts it.
  • WavEncoder writes each block as it arrives, so a long recording doesn’t sit in memory. With a lossy-typed format such as new AudioFormat(48000, 1) it writes 16-bit PCM; set WavEncoderSettings.SampleType for 24-bit or float. Finish patches the sizes in the header, and the file becomes RF64 by itself if it grows past what a plain WAV can describe. More in read and write WAV files in C#.
  • To record straight to a compressed file, swap the encoder: FlacEncoder for lossless FLAC or OpusFileWriter for Ogg Opus take the same Write and Finish calls.
  • The peak of −31.6 dBFS is the room’s background: nobody spoke during this run, and your levels will differ.

Show a live input level

For a level meter, or anything that reacts as sound arrives, give CreateInput a callback. It receives each block on the device thread, with the same rules as a playback callback: no allocation, no locks, no blocking, no I/O. Here it keeps only the block’s peak, and the main thread reads it twice a second:

Program.csOneAudio.Devices 1.2.1 · .NET 10
using OneAudio;
using OneAudio.Devices;
 
// A live input level: the callback gets each block on the device thread as it arrives.
// Like a render callback, it must not allocate, lock, block or do I/O, so it only stores the block's peak.
float[] lastPeak = new float[1];
using AudioInputDevice mic = Audio.CreateInput((ReadOnlySpan<float> block, in RenderContext ctx) =>
{
    float peak = 0;
    foreach (float s in block)
        peak = MathF.Max(peak, MathF.Abs(s));
    Volatile.Write(ref lastPeak[0], peak);
}, new DeviceStreamOptions { SampleRate = 48000, Channels = 1 });
 
if (mic.State == DeviceState.NoDevice)
    Console.WriteLine($"no input: {mic.LastError}");
 
for (int i = 0; i < 4; i++)
{
    Thread.Sleep(500);
    float db = 20 * MathF.Log10(Math.Max(Volatile.Read(ref lastPeak[0]), 1e-6f));
    Console.WriteLine($"{db,6:F1} dBFS {new string('#', (int)Math.Max(0, (db + 60) / 2))}");
}
Console.WriteLine($"{mic.Counters.FramesPlayed} frames delivered, overruns {mic.Counters.Overruns}");
OutputConsole · Linux
 -46.7 dBFS ######
 -38.9 dBFS ##########
 -37.4 dBFS ###########
 -42.4 dBFS ########
82560 frames delivered, overruns 0
  • An input opened with a callback has no read buffer: Read throws, because the callback already got every block.
  • FramesPlayed on an input counts the frames delivered to you (82,560 frames is 1.72 s at 48 kHz), and Overruns counts capture overruns, when audio arrived faster than it could be taken.
  • For a loudness reading in LUFS rather than a peak, feed the blocks to a LoudnessMeter from the core package, whose Process call also allocates nothing. See loudness normalization in C#.

Permissions and missing microphones

A microphone that can’t be opened isn’t an exception: mic.State is DeviceState.NoDevice, mic.LastError says why, and the input retries twice a second, as the second sample checks. The common reasons, per the device package’s API documentation:

  • Windows: the microphone privacy setting must allow desktop apps; a denied open says so in LastError.
  • macOS: if the user denied microphone access, opening fails with a message saying so. A command-line program without an app bundle and a microphone usage description may be unable to ask and receive silence.
  • iOS: the app needs a microphone usage description in its Info.plist, and a capture stream uses the play-and-record audio session.
  • Linux: the default input is the sound server’s default source. AudioDevices.List with DeviceDirection.Input lists the others, and DeviceId picks one.

Recording with NAudio

NAudio 3 records with WasapiRecorder or WaveInEvent on Windows and with AlsaIn, in the NAudio.Alsa package, on Linux. Its ALSA recording guide shows the pattern: set a WaveFormat, write each DataAvailable buffer to a WaveFileWriter, and dispose the writer in RecordingStopped. Following it, with a wait for that event added so the file is complete before it is read:

Program.csNAudio.Alsa 3.3.0 · Linux
using NAudio.Wave;
using NAudio.Wave.Alsa;
 
var waveFormat = new WaveFormat(48000, 16, 1);
var writer = new WaveFileWriter("recorded.wav", waveFormat);
var stopped = new ManualResetEventSlim();
 
using var input = new AlsaIn();                          // default capture device
input.WaveFormat = waveFormat;
input.DataAvailable += (sender, a) => writer.Write(a.Buffer, 0, a.BytesRecorded);
input.RecordingStopped += (sender, a) =>
{
    writer.Dispose();                                    // safe once the capture thread has finished
    stopped.Set();
};
 
input.StartRecording();
Thread.Sleep(3000);
input.StopRecording();
stopped.Wait();
 
using var check = new WaveFileReader("recorded.wav");
Console.WriteLine($"recorded.wav: {check.SampleCount} frames, {check.WaveFormat}");
OutputConsole · Linux
recorded.wav: 130048 frames, 16 bit PCM: 48000Hz 1 channels

The two models differ in where the work happens. NAudio raises an event with bytes in the device’s sample format, and its guide notes that the buffer is reused, so you copy or write it before the handler returns. OneAudio hands you float samples, either through a buffer you drain on your own thread or through a callback with real-time rules, and the counters (Overruns, FramesPlayed, DeviceLosses) say what the device did. If your app already records with NAudio, the API map in migrating from NAudio lists the equivalents.

Where next