To play audio in C# on Windows, Linux and macOS, add the RecursiveAI.OneAudio.Devices package, create a mixer with Audio.CreateMixer(), start the default output device with Audio.Play(mixer), and play a file on it with mixer.PlayStream("song.mp3"). The same code plays MP3, WAV, Ogg Vorbis, Opus and FLAC through WASAPI on Windows, Core Audio on macOS and iOS, and ALSA on Linux, which also reaches PipeWire and PulseAudio. Both packages are 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 real output device, and the outputs shown are from those runs.
Before you start
dotnet new console -o PlayAudio cd PlayAudio dotnet add package RecursiveAI.OneAudio.Devices --version 1.2.1
The device package brings in RecursiveAI.OneAudio, which does the decoding and mixing. Both target .NET 10 and are licensed MIT OR Apache-2.0. On Linux the package loads the system’s libasound, which desktop distributions install with their sound stack. The samples use intro.mp3, a five-second stereo clip at 44.1 kHz; use any file of your own.
Play an MP3 file
using OneAudio; using OneAudio.Devices; // The mixer is what the device plays: 48 kHz stereo, with a limiter on the master. AudioMixer mixer = Audio.CreateMixer(); using AudioOutputDevice output = Audio.Play(mixer); // opens the default output device and starts it Console.WriteLine($"{output.BackendName}: {output.Device?.Name ?? "no device"}, {output.DeviceFormat.SampleRate} Hz"); // A long file streams: it is decoded ahead on a background thread and converted to 48 kHz. using StreamingVoice song = mixer.PlayStream("intro.mp3"); Console.WriteLine($"playing {song.Source.LengthFrames} frames"); // The mixer reports the end of a voice as an event. while (!HasEnded(mixer, song.Voice)) Thread.Sleep(50); Thread.Sleep(200); // let the device play what is still queued DeviceCounters c = output.Counters; Console.WriteLine($"written {c.FramesWritten}, played {c.FramesPlayed} frames, peak {c.PeakPlayed:F3}, underruns {c.Underruns}"); static bool HasEnded(AudioMixer mixer, VoiceHandle voice) { while (mixer.TryGetEvent(out MixerEvent e)) if (e.Kind == MixerEventKind.VoiceEnded && e.VoiceId == voice.Id) return true; return false; }
alsa: default, 48000 Hz playing 220500 frames written 252720, played 251203 frames, peak 0.055, underruns 0
Audio.Play(mixer)opens the default output in shared mode with about 20 ms of buffer, starts it and returns the runningAudioOutputDevice. Dispose it to stop.PlayStreamsuits music and other long files: a background thread decodes ahead, so the file is never fully in memory. The 44.1 kHz MP3 is converted to the mixer’s 48 kHz as it plays, andLengthFramesis counted in the file’s own frames, so 220,500 is five seconds at 44.1 kHz. For short sounds,mixer.Play("click.wav")loads the file into memory and starts it at once.- The counters come from the device’s own position, not from what the program handed over:
FramesPlayedis what the hardware has played,PeakPlayedthe loudest sample among those frames, andUnderrunshow often it ran dry. Here the device kept playing silence for a moment after the clip ended, so it played more frames than the clip holds; the exact counts change a little from run to run. - The format is detected from the bytes: change the file name to a
.wav,.ogg,.opusor.flacfile and nothing else changes.
Play several sounds at once
Everything you play is a voice on the mixer, so overlapping sounds need no extra code: call mixer.Play again while the first is still playing. PlayOptions sets Gain, Pan, Pitch, Loop, a fade-in and the bus the voice feeds, and the mixer’s limiter on the master output keeps the sum from clipping. The mixer’s render call allocates nothing and takes no locks, so it can run on the device’s real-time thread. Buses, ducking and effects for games are covered in a zero-allocation audio mixer for C# games.
Choose a device and play samples you generate
To play audio that your own code computes, such as a synthesizer, a tone or a stream from the network, pass a callback instead of a mixer. This sample lists the outputs, opens the default one by id, and plays a 440 Hz tone for one second:
using OneAudio; using OneAudio.Devices; // What the system offers. On Linux the ids are ALSA PCM names; "default" follows the desktop's choice. IReadOnlyList<AudioDeviceInfo> outputs = AudioDevices.List(DeviceDirection.Output); AudioDeviceInfo? fallback = AudioDevices.Default(DeviceDirection.Output); Console.WriteLine($"{outputs.Count} outputs, default: {fallback?.Id} ({fallback?.Name})"); // Play samples you compute yourself: a 440 Hz tone for one second. // The callback runs on the device thread, so it must not allocate, lock or block. double phase = 0, step = 2 * Math.PI * 440 / 48000; var options = new DeviceStreamOptions { DeviceId = fallback?.Id, SampleRate = 48000, Channels = 2 }; using AudioOutputDevice output = Audio.CreateOutput((Span<float> buffer, in RenderContext ctx) => { for (int i = 0; i < buffer.Length; i += 2) // interleaved stereo: left, right { float s = 0.1f * (float)Math.Sin(phase); buffer[i] = s; buffer[i + 1] = s; phase = (phase + step) % (2 * Math.PI); } }, options); Thread.Sleep(1000); DeviceCounters c = output.Counters; Console.WriteLine($"{c.FramesPlayed} frames played, peak {c.PeakPlayed:F2}, buffer {c.BufferFrames} frames, {c.LatencyMilliseconds:F1} ms");
90 outputs, default: default (Default) 47681 frames played, peak 0.10, buffer 960 frames, 26.6 ms
- The callback fills an interleaved float buffer at the stream’s rate and channel count. It runs on the device thread, so it must not allocate, lock or block; an exception it throws is caught, counted in
CallbackFaults, and that block plays as silence. - On Linux the device ids are ALSA PCM names, which is why this machine reports 90: the list includes every card, every plug-in and every route.
defaultfollows the output the user picked on a PipeWire or PulseAudio desktop and converts rate and channels for you. On Windows the ids are WASAPI endpoint ids with their friendly names, and on macOS Core Audio devices. - Pass
DeviceId = null(the default) to use the system default and follow it when the user switches, for example to headphones.output.SelectDevice(id)moves a running stream to another device. - Any type that implements
IAudioSource, a sample rate, a channel count andRender(Span<float>), can be played withAudio.CreateOutput(source). The mixer is one of them.
When there is no device
A device that can’t be opened isn’t an exception. The output keeps calling your mixer or callback at real-time pace and discards the result, so a game clock keeps moving, retries the device twice a second, and says why in output.LastError; output.State tells you whether a device is open. A lost device, such as unplugged USB headphones, is reopened, and the DeviceLost, DeviceRestored and DefaultChanged events say what happened. That is also why a server or a CI machine without a sound card runs the same code without crashing.
Per the package’s changelog, the backends have been checked on real hardware on Linux (ALSA through PipeWire, playback and capture), on macOS (Core Audio, including following a change of default output) and on an iPad; the WASAPI backend for Windows is checked by unit tests and on Windows CI. On Windows, shared mode (the default) accepts any rate and channel count, and ShareMode.Exclusive asks for the device to itself with a lower latency, as the package README describes.
Other ways to play sound in C#
SoundPlayer
Most answers for “c# play sound” use System.Media.SoundPlayer. Microsoft’s documentation describes it as playback “of a sound from a .wav file” and notes that it “cannot play other file types, such as .wma or .mp3”. On .NET it comes from the System.Windows.Extensions package, and the compiler marks it Windows-only (warning CA1416). On Linux it fails at run time:
using System.Media; try { using var player = new SoundPlayer("intro.wav"); player.PlaySync(); } catch (Exception ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
PlatformNotSupportedException: System.Windows.Extensions types are not supported on this platform.NAudio
NAudio 3 plays through WasapiPlayer or WasapiOut on Windows and through AlsaOut, in the separate NAudio.Alsa package, on Linux. Which files it can play off Windows depends on the reader: its ALSA playback guide says AudioFileReader, Mp3FileReader and MediaFoundationReader are Windows-only, and offers NAudio.SoundFile (a system libsndfile) or the managed NLayer decoder for MP3. The NLayer route from that guide runs on Linux:
using NAudio.Wave; using NAudio.Wave.Alsa; using NLayer.NAudioSupport; // MP3 on Linux: NLayer's managed frame decompressor in Mp3FileReaderBase, played through ALSA. using var mp3 = new Mp3FileReaderBase("intro.mp3", waveFormat => new Mp3FrameDecompressor(waveFormat)); using var outputDevice = new AlsaOut(); // default device outputDevice.Init(mp3); outputDevice.Play(); while (outputDevice.PlaybackState == PlaybackState.Playing) Thread.Sleep(200); Console.WriteLine($"played {mp3.TotalTime.TotalSeconds:F2} s of {mp3.WaveFormat}");
played 5.04 s of 32 bit IEEEFloat: 44100Hz 2 channelsNAudio is a good choice when you need ASIO, MIDI or VST 3 hosting. If you only need to play files and sounds, OneAudio does it with one program for every OS, every format it decodes and no reader to choose per platform. Its MP3 decoder trims the encoder’s padding, so the five-second clip plays as exactly five seconds; the 5.04 s above includes the padding. Moving an NAudio app across is covered in migrating from NAudio.
Where next
- Record audio in C#, from the microphone to a WAV file.
- The cookbook recipes: play an audio file, list audio devices and mix audio.
- The device API overview: options, counters, events and the backends.
- Choosing a C# audio library.