Tutorial 137: Native Linux Audio with ALSA

CNA Tutorials  ·  CNA snapshot 009d40f5

ℹ

What you’ll learn: how to configure an SDL-free ALSA audio build, prove it links no SDL or libasound, pick devices with CNA_AUDIO_DEVICE and CNA_AUDIO_RECORDING_DEVICE, and play a SoundEffect, a Song and microphone capture from one small game.

ℹ

Before you start — Tutorial 03: Your First CNA Window for the project layout and CMakeLists.txt this tutorial reuses, and Tutorial 14 and Tutorial 15 for SoundEffect, Song and MediaPlayer. You need a Linux machine; ALSA does not exist anywhere else.

Until this snapshot, sound in CNA meant SDL3: the SDL3 audio device plus SDL3_mixer. CNA_AUDIO_PLATFORM=ALSA is a second implementation of the same XNA audio surface that uses no SDL at all. It opens Linux’s own audio interface, ALSA, and mixes with CNA’s own mixer (CnaMixer), so SoundEffect, SoundEffectInstance, DynamicSoundEffectInstance, MediaPlayer, the XACT runtime and Microphone all work in a build that never touches SDL. In this tutorial you configure such a build, prove it is SDL-free, choose an ALSA device with an environment variable, and play a sound effect, a song and read the microphone from one small game.

⚠

What is and is not proven. CI runs the ALSA path on a runner with no sound card: the x11-sdl-free-gpu job builds this configuration, checks that the executables link neither SDL nor libasound, and runs the mixer, ALSA device, microphone, category and engine test suites on ALSA’s silent null device. That proves the mixer and the device layer. Nothing in CI listens to real speakers or records from a real microphone, and the cna_demo_sound demo is not among the targets CI builds in this configuration.

How ALSA fits in

PieceWhat it does in the ALSA build
AlsaAudioDevicePlays through the ALSA PCM named by CNA_AUDIO_DEVICE (default: default). About 10 ms periods, four buffered, on a playback thread. The device may answer with another rate, channel count or sample format than requested; CNA reports and uses what it chose.
libasound.so.2Loaded at run time, never linked. A machine without it still starts your program; opening the device then fails with a message that says why, which the XNA layer reports as NoAudioHardwareException. Only the ALSA headers are needed to build.
CnaMixerImplements the same internal mixer facade as SDL3_mixer: per-track resampling (linear interpolation), gain, a per-track mix callback where the instance’s filter and pan run, then the master mix and a post-mix callback. Loops, pause and per-track stop callbacks follow SDL3_mixer’s semantics; one documented difference is that a track’s stop callback fires after its last audio has passed through the mix callback.
DecodersWAV (PCM 8/16/24/32-bit, IEEE float, MS-ADPCM, IMA-ADPCM) written by CNA; Ogg Vorbis through stb_vorbis; MP3 through dr_mp3; FLAC (native or Ogg) through dr_flac. Formats are recognised by content, not file extension.
AlsaAudioRecordingDeviceCapture for Microphone. A thread reads the PCM and queues up to eight seconds, so a game that polls once a frame loses nothing between polls.

Because ALSA is on every Linux system, one backend reaches the whole desktop: on a PipeWire system ALSA’s default device is PipeWire (through pipewire-alsa), on a PulseAudio system it is PulseAudio (through the ALSA PulseAudio plugin), and on a bare system it is the sound card through dmix. CNA does not talk to PipeWire or PulseAudio directly, and there is no OpenAL or WASAPI backend (OPENAL and WASAPI are reserved names that fail configuration).

Prerequisites

This snapshot of CNA is the next branch, and it needs next of sharp-runtime too. With the software renderer used below you do not need easy-gl or meta-gl.

mkdir cna-workspace && cd cna-workspace
git clone -b next https://github.com/libcna/cna.git
git clone -b next https://github.com/libcna/sharp-runtime.git
cd cna
git submodule update --init          # non-recursive is correct

Install a compiler (the CI runs GCC 14), CMake 3.20 or newer, pkg-config and the development packages for the X11 window that this walkthrough uses, plus the ALSA headers:

# Debian / Ubuntu
sudo apt install g++ cmake pkg-config \
  libx11-dev libxext-dev libasound2-dev

# Fedora: alsa-lib-devel provides the ALSA headers

The list above is what CMake’s X11 check makes mandatory (libX11, libXext and X11/XKBlib.h) plus the ALSA headers; CI installs a larger set, and I have not measured the minimum. The optional X libraries (libxrandr-dev, libxi-dev, libxcursor-dev and so on) each turn on one capability, and Native Platforms lists them. The libasound2 runtime library is what libasound2-dev depends on and is present on any desktop; for PipeWire or PulseAudio routing, also install that sound server’s ALSA plugin. FFmpeg is not needed.

Configure and build

cmake -S . -B build-alsa \
  -DCNA_ENABLE_SDL=OFF \
  -DCNA_PLATFORM=X11 \
  -DCNA_AUDIO_PLATFORM=ALSA \
  -DCNA_GRAPHICS_RENDERER=SOFTWARE \
  -DCNA_ENABLE_NET=OFF

cmake --build build-alsa --target CnaAudioTests cna_demo_sound --parallel
OptionWhy
CNA_ENABLE_SDL=OFFSDL is not downloaded, built, found or linked. Any selection that needs it is refused by name.
CNA_PLATFORM=X11The native Xlib backend. The default SDL3 would be refused by CNA_ENABLE_SDL=OFF. (WAYLAND works the same way; see Tutorial 136.)
CNA_AUDIO_PLATFORM=ALSAThe subject of this tutorial. Fails at configure time on a non-Linux target, and without the ALSA headers.
CNA_GRAPHICS_RENDERER=SOFTWAREA CPU renderer that presents through X11 and needs no GL or Vulkan packages. OPENGL33 (needs the easy-gl and meta-gl siblings) is what CI’s SDL-free GPU job uses.
CNA_ENABLE_NET=OFFOptional. Skips the vendored ENet and networking layer, as CI’s SDL-free jobs do.

Read the configure output. You should see CNA: Using X11 platform implementation, CNA: Using ALSA audio platform implementation and CNA: SDL is NOT configured (CNA_ENABLE_SDL=OFF). If you would rather keep an SDL3 window and only replace the audio, -DCNA_PLATFORM=SDL3 -DCNA_AUDIO_PLATFORM=ALSA (without CNA_ENABLE_SDL=OFF) is accepted by the selection rules; CI does not exercise that pairing.

✓

Mixing SDL2 and SDL3 is refused; ALSA mixes with neither. -DCNA_PLATFORM=SDL2 alone fails, because the audio default is SDL3. The refusal message itself recommends ALSA (or NULL) as an audio platform an SDL2 host can share a process with.

Prove that nothing links SDL or libasound

These are the checks CI runs (its SDL-free jobs use readelf, ldd and find). None of them should print a match:

find build-alsa -iname '*SDL2*' -o -iname '*SDL3*'
ldd build-alsa/CnaAudioTests | grep -i sdl
ldd build-alsa/CnaAudioTests | grep -i asound

The first two prove the SDL-free claim (no SDL artifact was built, and none is loaded, including through CNA’s own libcna.so, which native Linux builds link by default when CMake is 3.27 or newer). The third proves libasound is opened with dlopen at run time rather than linked, so the binary starts on a machine without it.

Run the audio suites on a silent device

Point ALSA at its null device for both playback and capture and run the suites CI runs. No test makes a sound or records a room:

CNA_AUDIO_DEVICE=null CNA_AUDIO_RECORDING_DEVICE=null \
  ./build-alsa/CnaAudioTests \
  --gtest_filter='CnaMixer.*:CnaMixerXna.*:AlsaAudioDevice.*:AlsaAudioRecordingDevice.*:Microphone*:AudioCategory*:*AudioDeviceConformanceTests*:AudioEngine*:RendererDetail*'

These cover the mixer arithmetic (CnaMixer), the XNA facade running on it (CnaMixerXna), the ALSA device and capture sessions, and the XACT engine and categories. ALSA’s null device has no clock and swallows any amount at once, so CNA paces it in real time: a one-second sound still takes one second.

Choosing devices

VariableValueMeaning
CNA_AUDIO_DEVICE(unset)The ALSA default PCM — the sound server’s device on PipeWire and PulseAudio systems.
hw:0,0, plughw:1,0A specific card and device. hw: is exact and may refuse a format the hardware lacks; plughw: converts.
nullSilent. Everything runs, nothing is audible. What the test suites use.
file:FILE=out.raw,FORMAT=rawWrites exactly what was played to out.raw, in whatever format the device negotiated.
CNA_AUDIO_RECORDING_DEVICEany capture PCMExactly that PCM becomes the default microphone. null keeps tests from recording. Unset: ALSA’s default capture PCM first, then every sound card’s capture devices opened through plughw.

ALSA’s own tools list what exists on your machine: aplay -L for playback PCM names and arecord -L for capture (package alsa-utils). The variables are read when the device is created, so set them in the environment of the process. They are ALSA-only: under the SDL3 audio implementation the equivalent knob is SDL’s own SDL_AUDIODRIVER.

The game

The program below needs no audio file for the sound effect: it synthesises a 200 ms beep as headerless 16-bit PCM and hands it to the SoundEffect(buffer, sampleRate, channels) constructor. For the song, put any Ogg Vorbis, MP3 or FLAC file at Content/music/theme.ogg (for example ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -c:a libvorbis Content/music/theme.ogg if you have the FFmpeg command-line tool). Space plays the beep, M starts, pauses and resumes the song, Escape quits, and the window title shows the microphone’s peak level.

#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <exception>
#include <iostream>
#include <optional>
#include <string>
#include <vector>

#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Audio/AudioChannels.hpp"
#include "Microsoft/Xna/Framework/Audio/Microphone.hpp"
#include "Microsoft/Xna/Framework/Audio/NoAudioHardwareException.hpp"
#include "Microsoft/Xna/Framework/Audio/SoundEffect.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/Media/MediaPlayer.hpp"
#include "Microsoft/Xna/Framework/Media/Song.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Audio;
using namespace Microsoft::Xna::Framework::Input;
using namespace Microsoft::Xna::Framework::Media;

// A 200 ms, 660 Hz mono beep built in memory: headerless 16-bit PCM, no file needed.
static SoundEffect MakeBeep()
{
    constexpr int kRate = 44100;
    constexpr int kFrames = kRate / 5;
    std::vector<SharpRuntime::bytecs> pcm(static_cast<std::size_t>(kFrames) * 2);
    for (int i = 0; i < kFrames; ++i)
    {
        const double fade = 1.0 - static_cast<double>(i) / kFrames;
        const auto sample = static_cast<std::int16_t>(
            std::sin(2.0 * 3.14159265358979 * 660.0 * i / kRate) * 0.3 * fade * 32767.0);
        pcm[static_cast<std::size_t>(i) * 2 + 0] = static_cast<SharpRuntime::bytecs>(sample & 0xFF);
        pcm[static_cast<std::size_t>(i) * 2 + 1] = static_cast<SharpRuntime::bytecs>((sample >> 8) & 0xFF);
    }
    return SoundEffect(pcm, kRate, AudioChannels::Mono);
}

class AlsaAudioDemo final : public Game
{
public:
    AlsaAudioDemo() : graphics_(this) {}

protected:
    void LoadContent() override
    {
        try
        {
            beep_ = MakeBeep();
        }
        catch (const NoAudioHardwareException& ex)     // no libasound, or the PCM cannot be opened
        {
            std::cerr << "No audio output: " << ex.what() << "\n";
        }

        try
        {
            song_ = getContentProperty().Load<Song>("music/theme");   // .mp3 .ogg .wav .flac ...
        }
        catch (const std::exception& ex)
        {
            std::cerr << "No song: " << ex.what() << "\n";
        }

        mic_ = Microphone::getDefaultProperty();                      // nullptr when there is none
        if (mic_ != nullptr)
        {
            mic_->setBufferDurationProperty(System::TimeSpan::FromMilliseconds(100));
            mic_->BufferReady += [this](System::Object*, const System::EventArgs&) { OnMicBuffer(); };
            mic_->Start();
        }
    }

    void UnloadContent() override
    {
        MediaPlayer::Stop();
        if (mic_ != nullptr) { mic_->Stop(); }
    }

    void Update(GameTime& gameTime) override
    {
        Game::Update(gameTime);   // pumps FrameworkDispatcher: Microphone.BufferReady, MediaPlayer

        const KeyboardState kb = Keyboard::GetState();
        auto pressed = [&](Keys key) { return kb.IsKeyDown(key) && prev_.IsKeyUp(key); };

        if (pressed(Keys::Space) && beep_) { beep_->Play(0.8f, 0.0f, 0.0f); }

        if (pressed(Keys::M) && song_)
        {
            switch (MediaPlayer::getStateProperty())
            {
                case MediaState::Playing: MediaPlayer::Pause();  break;
                case MediaState::Paused:  MediaPlayer::Resume(); break;
                case MediaState::Stopped: MediaPlayer::setIsRepeatingProperty(true);
                                          MediaPlayer::Play(&*song_); break;
            }
        }
        if (pressed(Keys::Escape)) { Exit(); }
        prev_ = kb;
    }

    void Draw(const GameTime&) override
    {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::CornflowerBlue);
        // No gd.Present(): Game presents in EndDraw, after Draw() returns.
    }

private:
    void OnMicBuffer()
    {
        std::vector<SharpRuntime::bytecs> buf(
            static_cast<std::size_t>(mic_->GetSampleSizeInBytes(System::TimeSpan::FromMilliseconds(100))));
        const SharpRuntime::intcs got = mic_->GetData(buf);           // bytes of mono int16
        int peak = 0;
        for (SharpRuntime::intcs i = 0; i + 1 < got; i += 2)
        {
            const auto s = static_cast<std::int16_t>(buf[i] | (buf[i + 1] << 8));
            peak = std::max(peak, std::abs(static_cast<int>(s)));
        }
        getWindowProperty().setTitleProperty("mic peak " + std::to_string(peak * 100 / 32768) + " %");
    }

    GraphicsDeviceManager graphics_;
    std::optional<SoundEffect> beep_;
    std::optional<Song> song_;
    Microphone* mic_ = nullptr;
    KeyboardState prev_;
};

int main()
{
    AlsaAudioDemo game;
    game.Run();
}

Things worth noticing. Load<Song> returns the Song by value and MediaPlayer::Play takes a Song*, hence the std::optional and &*song_. SoundEffect is move-only, hence MakeBeep() returning by value into an optional. The microphone delivers mono, signed 16-bit at a requested 44,100 Hz, BufferDuration must be 100–1000 ms in steps of 10 ms, and BufferReady is raised from Game::Update through FrameworkDispatcher, so it only fires if your Update calls the base class. Microphone::getDefaultProperty() is nullptr when there is no capture device, which is also what you get if libasound is missing.

Project files

Lay the project out as a sibling of cna/, as in Tutorial 03: alsa-audio-demo/CMakeLists.txt, alsa-audio-demo/main.cpp (the program above) and alsa-audio-demo/Content/music/theme.ogg. The platform, audio and SDL selections are cache variables that must be set before add_subdirectory; a value already set on the command line wins.

cmake_minimum_required(VERSION 3.20)
project(AlsaAudioDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

set(CNA_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../cna")

# SDL-free X11 window + ALSA audio + a CPU renderer
set(CNA_ENABLE_SDL OFF CACHE STRING "")
set(CNA_PLATFORM X11 CACHE STRING "")
set(CNA_AUDIO_PLATFORM ALSA CACHE STRING "")
set(CNA_GRAPHICS_RENDERER SOFTWARE CACHE STRING "")
set(CNA_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(CNA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
add_subdirectory(${CNA_DIR} ${CMAKE_BINARY_DIR}/cna)

add_executable(AlsaAudioDemo main.cpp)
target_link_libraries(AlsaAudioDemo PRIVATE CNA)

# Ship the Content directory next to the executable
add_custom_command(TARGET AlsaAudioDemo POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_directory
        ${CMAKE_CURRENT_SOURCE_DIR}/Content
        $<TARGET_FILE_DIR:AlsaAudioDemo>/Content)
cd alsa-audio-demo
cmake -S . -B build
cmake --build build
cd build
./AlsaAudioDemo                              # your default output and microphone
CNA_AUDIO_DEVICE=null ./AlsaAudioDemo        # everything runs, silently

The default content root is Content, resolved against the working directory, which is why you run from the directory that holds it. To see CNA’s own audio demo on the build from the first half of this tutorial, run cd build-alsa && ./cna_demo_sound; its controls are the ones Tutorial 14 lists.

Honest limits

  • Linux only. CNA_AUDIO_PLATFORM=ALSA is a configure error on any other target. On Windows and macOS there is no SDL-free audio path: with CNA_ENABLE_SDL=OFF only NULL (silent) is available there.
  • Formats. WAV, Ogg Vorbis, MP3 and FLAC. No Opus (an Ogg Opus file is refused with a message naming the formats CNA does play), no WMA/xWMA, no XMA, no AAC or .m4a. A ContentManager name resolving to a .opus, .aac or .wma file does not make it playable.
  • Songs sit in memory. A Song is not pre-decoded, but its compressed bytes are read into memory and decoded as they play.
  • Resampling is linear interpolation — the same method FAudio, and therefore FNA, uses.
  • Hardware evidence is thin. CI uses the null device. Whether it sounds right on your sound card, over Bluetooth or through your particular PipeWire graph is not something a workflow has checked.
  • No XNA reverb or HRTF on either mixer; see the Audio reference for the XACT and 3D limits, which are the same on SDL3 and ALSA.

Troubleshooting

SymptomCause and fix
Configure fails: “needs the ALSA development headers”Install libasound2-dev (Debian/Ubuntu) or alsa-lib-devel (Fedora).
Configure fails: “CNA_ENABLE_SDL=OFF, but this configuration genuinely requires SDL”The message names the selections that need SDL. The defaults for platform and audio are both SDL3: set CNA_PLATFORM and CNA_AUDIO_PLATFORM explicitly, and pick a renderer other than SDL_RENDERER, SDL_GPU, FNA3D or FREEDIRECT.
Configure fails: “CNA_PLATFORM=X11 was requested but this machine cannot build it”The message names the missing X package. It never falls back to SDL3.
NoAudioHardwareException at run timelibasound.so.2 is missing or the PCM cannot be opened; the exception text carries ALSA’s reason. Try CNA_AUDIO_DEVICE=null to separate “the device is wrong” from “the code is wrong”, and aplay -L for valid names.
The song does not playThe file is Opus, AAC or WMA, or is not what its extension says. CNA recognises formats by content and names the ones it plays in the error. Convert to Ogg Vorbis.
Microphone::getDefaultProperty() is nullptrNo capture PCM is enumerated, libasound is missing, or CNA_AUDIO_RECORDING_DEVICE names something that does not exist.

Where to go next