Building your own audio player with .NET – part 2

This is the second part of our series of tutorials on building audio capabilities into .NET, which the platform doesn’t have out of the box. In the first tutorial of this series, we set up a basic project structure and added a class that enabled us to play audio on Windows.

However, .NET Core (which then evolved into .NET) wasn’t created for Windows alone. Therefore, in this tutorial, we will add Linux playback capabilities to our application.

Introducing ALSA

Advanced Linux Sound Architecture (ALSA) is a de-facto standard suite of software that enables audio playback on Linux operating systems. Of course, being an open-source OS, there is no 100% guarantee that a particular distribution of Linux will have ALSA. However, all of the mainstream distributions, such as DebianUbuntu, and Fedora do. Therefore, if you write any Linux software that specifically relies on ALSA audio playback capabilities, chances are that it will work on the vast majority of Linux machines.

ALSA comes with a handy command line utility called aplay, which is used to play audio from specified files. The utility comes with an intuitive command syntax. For example, to play an MP3 file called “audio.mp3” that is located inside of the currently selected folder, you can use the following command:

aplay audio.mp3

Another useful utility that comes with ALSA is amixer. This utility allows you to adjust playback volumes on different hardware devices on any available audio cards. In this context, a device is something that is connected to a particular hardware output. For example, there will be a distinct device used by HDMI output and another device used by a standard audio jack.

To adjust the volume on the first device from the default master device to 75%, you can execute the following command:

amixer sset 'Master' 75%

There are many other things that you can do with various ALSA command line utilities, which are beyond the scope of this article. However, one relevant thing is the fact that .NET allows you to execute any arbitrary commands directly from the code, regardless of what OS it’s running on.

Execute Linux shell commands from your code

Remember the IPlayer interface that we implemented in the first part of our tutorial that looked like this?

namespace NetCoreAudio.Interfaces;

public interface IPlayer
{
    Task Play(string fileName);
    Task Pause();
    Task Resume();
    Task Stop();
}

We will now add a Linux implementation of it as a separate class. Let’s call it LinuxPlayer.

Once the class is created with placeholders for the methods, we can add the following private field to it:

private Process _process = null;

SystemDiagnostics.Process is an in-built class from the standard library that allows you to start any new processes. In this case, it will enable us to use aplay on Linux. This will be achieved by the following private method:

private Process StartAplayPlayback(string fileName)
{
  var escapedArgs = fileName.Replace("\"", "\\\"");

  var process = new Process()
  {
    StartInfo = new ProcessStartInfo
    {
      FileName = "/bin/bash",
      Arguments = $"-c \"aplay {escapedArgs}\"",
      RedirectStandardOutput = true,
      RedirectStandardInput = true,
      UseShellExecute = false,
      CreateNoWindow = true,
    }
  };
  process.Start();
  return process;
}

I’ll have to explain what are we doing here.

First, we need to escape slashes in our file name. This is so they are actually processed as slash symbols by the process and not as escape characters.

The FileName field of ProcessStartInfo is set to the standard Linux Bash (equivalent of cmd on Windows). This allows us to run any commands that you would normally be able to from a Linux terminal.

The Arguments field consists of the actual aplay command with the file name set as the parameter. Normally, if we would be operating aplay directly from the terminal, we could also add the “i” flag, which allows us to play audio in interactive mode, so we will be able to pause and resume it from the standard input from within the process. However, as we are running it from the code, we will achieve these abilities by different means. More on this later.

As we are not using the default shell from the operating system, we are setting the UseShellExecute field to false. We are running the command in a completely headless mode inside of the background process. Therefore we are setting CreateNoWindow field to true.

Finally, to enable us to inject input into the process while it’s running and read the output right from the code, we are setting RedirectStandardOutput and RedirectStandardInput fields to true.

We can now call the StartAplayPlayback() method from the implementation of the Play() method inside the LinuxPlayer class.

Implementation of Stop() method can be done as follows:

public Task Stop()
{
  if (_process != null)
  {
    _process.Kill();
    _process.Dispose();
    _process = null;
  }
  return Task.CompletedTask;
}

This will kill the process, clear out any unmanaged resources used by the Process object, and will set its value to null.

An additional call to the Stop() method can be made from the start of the Play() method. This will stop any current playback before starting a new playback from the beginning.

Finally, we will add the ability to pause and resume the playback. To achieve this on any Unix-based operating system, we can use a parametrized “kill” command against a unique process id to pause and resume it. This can be applied to virtually any type of process.

The process id that we need is stored inside “Id” property of the original Process object. This is why we have made it global earlier.

Pausing and resuming a process is performed by -STOP and -CONT parameters respectively. Therefore, our Pause() method will need to create another Process object that uses bash and send the following string to it:

$"kill -STOP {_process.Id}"

Likewise, the following string can be used inside of the Resume() method:

$"kill -CONT {_process.Id}"

Dynamically choose the IPlayer implementation

The library that we are building should work, regardless of the OS that you would run it on. Therefore, we will need to be able to identify the OS and dynamically apply the correct implementation of the IPlayer interface accordingly.

This is easily achievable in .NET. Assuming that we have called our classes WindowsPlayer and LinuxPlayer and that we have declared a private field of a type IPlayer called _internalPlayer, the following code will apply the correct implementation:

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
  _internalPlayer = new WindowsPlayer();
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
  _internalPlayer = new LinuxPlayer();

Wrapping up

So, if you have been following this tutorial starting from part one, you should now be able to build a .NET app that can play, pause, resume, and stop audio on Windows and Linux.

In both cases, I have provided an overview of how this is done, instead of guiding you through all of the details line by line. Therefore, if you got confused at any point, you will be able to see how it all fits together inside NetCoreAudio project.

In the next part of the tutorial, I will describe how to enable the same capabilities on Mac OS.

Part 1 of the tutorial

Part 3 of the tutorial