I’ve been on a spiritual search for a cross-platform solution that allows developers to play and capture audio using .NET 5. It’s a journey filled with many highs and lows, mostly cursing at my screen and ruing the day I ever embarked on this mission. That said, I’ve found a way to play audio using a cross-platform API, even though the approach itself can differ across Windows, macOS, and Linux. This post will show the process we can take to get audio functionality in our .NET console applications.

The Painful Obvious Truth

As a professional for many years, I’ve learned that it’s best to use the platform and avoid abstractions, including database engines, web frameworks, and mobile platforms. While generalizations can increase code reuse, they can also limit functionality to the lowest common denominator. So my journey to find a cross-platform library that was able to play audio cross-platform using .NET purely had doomed from the start.

Through my searches, I found a library called NetCoreAudio. While I was looking through the project’s code, the real solution was painfully obvious.

Use the Operating System and its ability to play audio. –Inner Monologue

Being a macOS user, I could rely on a system utility to play audio from .NET and manage the child process’s lifecycle. NetCoreAudio attempts to create a cross-platform abstraction, but it reduces the native operating system utility’s functionality in the process. Additionally, I found that stopping audio playback was wonky, and the API didn’t take advantage of async/await and CancellationToken constructs.

We can do better!

AFPlay on macOS

Afplay is a macOS command-line utility that allows users to play an audio file via the terminal.

> afplay music.wav -t 5

This utility has many optional flags that can alter the playback of an audio file.

Syntax
      afplay [option...] audio_file

Options: (may appear before or after arguments)
   -v VOLUME
   --volume VOLUME
        Set the volume for playback of the file
        Apple does not define a value range for this, but it appears to accept
        0=silent, 1=normal (default) and then up to 255=Very loud.
        The scale is logarithmic and in addition to (not a replacement for) other volume control(s).
   -h
   --help
        Print help.

   --leaks
        Run leaks analysis.

   -t TIME
   --time TIME
        Play for TIME seconds
        >0 and < duration of audio_file.

   -r RATE
   --rate RATE
        Play at playback RATE.
        practical limits are about 0.4 (slower) to 3.0 (faster).

   -q QUALITY
   --rQuality QUALITY
        Set the quality used for rate-scaled playback (default is 0 - low quality, 1 - high quality).

   -d
   --debug
        Debug print output.

There’s a lot of great functionality in this utility, and we should take advantage of as much as possible. Be careful with the volume flag; I scared the hell out of my wife and dogs by setting the max value.

Implementing The Afplay C# Wrapper

Folks might feel opposed to wrapping OS utilities, but the .NET library defers many of its cryptographic and networking tasks to the underlying operating system. We should embrace the uniqueness of our platform and utilize its strengths.

In my case, I primarily work on macOS, so I’ll be writing a .NET wrapper for macOS users only. If you need to adapt a wrapper for a specific operating system, I recommend looking at NetCoreAudio and porting its functionality.

Knowing I had to work with processes, I decided to defer process management responsibilities to potentially two .NET OSS projects: SimpleExec and CliWrap. I would recommend you use CliWrap if you need CancellationToken support. Both libraries are excellent abstractions of the Process class.

Let’s take a look at the example project first.

using System;
using System.Threading;
using System.Threading.Tasks;
using GhostBusters;

Console.WriteLine("⚡️ peeeew! (Slow lazer)");
await Audio.Play("lazer.wav", new PlaybackOptions { Rate = 0.5, Quality = 1 });

Console.WriteLine("👻 Oooooooo!");
await Audio.Play("ghost.wav");

Console.WriteLine("Don't Cross The Streams!!!!");

// all the ghost busters now
await Task.WhenAll(
    Audio.Play("lazer.wav"),
    Audio.Play("lazer.wav", new PlaybackOptions { Rate = 0.4, Quality = .5 }),
    Audio.Play("lazer.wav", new PlaybackOptions { Rate = 0.8, Quality = 1 }),
    Audio.Play("lazer.wav", new PlaybackOptions { Rate = 0.6, Quality = 1 })
);

var cancellation = new CancellationTokenSource();

var keyBoardTask = Task.Run(() => {
    Console.WriteLine("Press enter to cancel the birthday song...");
    Console.ReadKey();

    // Cancel the task
    cancellation.Cancel();
});

var happyBirthday = Audio.Play(
    "happy-birthday.wav", 
    new PlaybackOptions(), 
    cancellation.Token
);

await Task.WhenAny(keyBoardTask, happyBirthday);

if (cancellation.IsCancellationRequested) {
    Console.WriteLine("🥳 Someone's a party pooper...");
}

We can call the Audio class’ Play method and pass all of the utility parameters accepted by afplay. Additionally, we are using async/await syntax to ensure that an audio file only moves on to the next line was the file has completed playing. Finally, we can pass a CancellationToken to our Play method to abruptly stop playing the audio file. The utility has no pause and resume functionality, which I wish it did.

Let’s take a look at the implementation.

using System.Threading.Tasks;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using CliWrap;

namespace GhostBusters
{
    public static class Audio
    {
        public static async Task<PlaybackResult> Play(
            string filename,
            PlaybackOptions options = null,
            CancellationToken token = default
        )
        {
            options??= new PlaybackOptions();
            var arguments = options.GetArguments();
            // add filename
            arguments.Add(filename);

            var sb = new StringBuilder();
            var command =
                await Cli
                    .Wrap("afplay")
                    .WithArguments(string.Join(" ", arguments))
                    .WithStandardOutputPipe(PipeTarget.ToStringBuilder(sb))
                    .ExecuteAsync(token);

            var output = sb.ToString();
            return new PlaybackResult(output);
        }
        
    }
    
    public record PlaybackResult 
    {
        public PlaybackResult(string result)
        {
            if (result == null)
                return;

            var reader = new StringReader(result);
            Filename = reader.ReadLine()?.Split(':')[1].Trim();
            
            var line = reader.ReadLine();
            Format = line?.Substring(line.LastIndexOf(':') + 1).Trim();

            var sizes = reader.ReadLine()?.Split(',');

            if (sizes != null)
            {
                BufferByteSize = int.Parse(sizes[0].Split(':').LastOrDefault() ?? "0");
                NumberOfPacketsToRead = int.Parse(sizes[1].Split(':').LastOrDefault() ?? "0");
            }
        }
        
        public string Filename { get; }
        public string Format { get; }
        public int BufferByteSize { get; }
        public int NumberOfPacketsToRead { get; }
    }

    public class PlaybackOptions
    {
        /// <summary>
        /// Set the volume for playback of the file
        /// Apple does not define a value range for this, but it appears to accept
        /// 0=silent, 1=normal (default) and then up to 255=Very loud.
        /// The scale is logarithmic and in addition to (not a replacement for) other volume control(s).
        /// </summary>
        public int? Volume { get; init; }
        /// <summary>
        /// Play for Time in Seconds
        /// </summary>
        public int? Time { get; init; }
        /// <summary>
        ///  Play at playback RATE.
        ///  practical limits are about 0.4 (slower) to 3.0 (faster).
        /// </summary>
        public double? Rate { get; init; }
        
        /// <summary>
        /// Set the quality used for rate-scaled playback (default is 0 - low quality, 1 - high quality).
        /// </summary>
        public double? Quality { get; init; }

        internal List<string> GetArguments()
        {
            var arguments = new List<string> { "-d" };

            if (Volume.HasValue) {
                arguments.Add($"-v {Volume}");
            }
            if (Time.HasValue)  {
                arguments.Add($"-t {Time}");
            }
            if (Rate.HasValue)  {
                arguments.Add($"-r {Rate}");
            }
            if (Quality.HasValue)  {
                arguments.Add($"-q {Quality}");
            }

            return arguments;
        }
    }
}

We have a fully-featured C# wrapper around a native audio player in just a few lines of code. CliWrap and macOS are doing much of the heavy lifting here.

Users of macOS can clone the sample GitHub repository.

Conclusion

While it would be nice to have a pure C# solution that works regardless of the operating system, it’s impossible to achieve that goal (as far as I know). Given each OS has its own set of permissions and access to hardware, this solution seems to be the best I can muster. NetCoreAudio is an excellent library, and folks should look at it if they need a cross-platform solution. Still, folks should also consider writing their own wrapper using CliWrap to get better async/await support.

Thanks for reading, and I hope you found this post helpful. Please leave a comment below.