Skip to content

Audio

PixelRoot32 includes a complete NES-like audio system with 4 channels for sound effects and background music. This guide shows you how to add sound and music to your games.

Audio Configuration

Before using audio, you need to configure an AudioBackend. This is done when creating the Engine:

ESP32: Internal DAC (Retro/Low-Cost)

The internal DAC is ideal for rapid prototyping or "Game Boy" style sounds.

PAM8302A Connection:

PAM8302A ESP32 Notes
VCC 5V (or 3.3V*)
GND GND
A+ (IN+) GPIO25 (DAC1) Or GPIO26 (DAC2)
A- (IN-) GND
SPK+ Speaker +
SPK- Speaker -
#include <drivers/esp32/ESP32_DAC_AudioBackend.h>

const int DAC_PIN = 25; // GPIO 25 or 26
// 11025 Hz is recommended for the internal DAC
pixelroot32::drivers::esp32::ESP32_DAC_AudioBackend audioBackend(DAC_PIN, 11025);

pixelroot32::audio::AudioConfig audioConfig(&audioBackend, audioBackend.getSampleRate());

Limitation: The internal DAC has an 8-bit resolution, producing a constant background noise ("hiss"). The driver operates in software mode to avoid hardware conflicts.

For clean and professional audio quality, use an I2S module like the MAX98357A.

MAX98357A Connection:

MAX98357A ESP32 Notes
VIN 5V Recommended for higher power
GND GND
BCLK GPIO26 Bit Clock
LRC (WS) GPIO25 Word Select
DIN GPIO22 Data In
SD 3.3V Or GPIO control for Mute
GAIN GND / NC Configurable per datasheet
SPK+ Speaker +
SPK- Speaker -
#include <drivers/esp32/ESP32_I2S_AudioBackend.h>

const int I2S_BCLK = 26;
const int I2S_LRCK = 25;
const int I2S_DOUT = 22;

pixelroot32::drivers::esp32::ESP32_I2S_AudioBackend audioBackend(
    I2S_BCLK, I2S_LRCK, I2S_DOUT, 22050
);

pixelroot32::audio::AudioConfig audioConfig(&audioBackend, 22050);

Native (PC): SDL2

#include <drivers/native/SDL2_AudioBackend.h>

pixelroot32::drivers::native::SDL2_AudioBackend audioBackend(22050, 1024);

pixelroot32::audio::AudioConfig audioConfig(&audioBackend, 22050);

Architecture: Decoupled and Automatic Hardware Adaptation

PixelRoot32 uses a high-performance audio architecture that completely decouples audio processing from the main game loop and automatically adapts to your hardware's capabilities.

  • Automatic Core Management (ESP32):
  • Dual-Core: The audio subsystem automatically pins itself to Core 0, while your game logic and rendering run on Core 1. This prevents audio "stuttering" even when the game is performing heavy calculations.
  • Single-Core (ESP32-S2/C3/etc.): The engine detects the single-core configuration and runs the audio task with high priority on the only available core, letting FreeRTOS handle the multitasking efficiently.
  • Sample-Accurate Timing: Unlike many engines that update audio once per frame, PixelRoot32 uses a sample-based sequencer. This ensures that music and sound effects have perfect timing, independent of the game's frame rate.
  • Lock-Free Communication: When you call playEvent() or musicPlayer.play(), the engine enqueues a command into a thread-safe, lock-free queue. The audio core picks up these commands asynchronously.

The engine handles all hardware detection and task pinning automatically via the PlatformCapabilities system. You never have to call an update() method for audio in your game code.

PlatformCapabilities and Task Pinning

The engine uses the PlatformCapabilities structure to determine the optimal threading strategy for your specific hardware.

  • hasDualCore: Indicates if the hardware supports parallel execution of game and audio tasks.
  • audioCoreId / mainCoreId: Specifies which CPU core is assigned to each subsystem. On dual-core ESP32, audio typically runs on Core 0 while the game loop runs on Core 1.
  • audioPriority: The task priority assigned to the audio sequencer to ensure glitch-free playback.

You can inspect the detected capabilities through the engine:

const auto& caps = engine.getPlatformCapabilities();
if (caps.hasDualCore) {
    // Audio is running on a dedicated core (Core 0 by default)
}

Sound Effects

Sound effects are created using AudioEvent structures and played through the AudioEngine.

AudioEvent Structure

#include <audio/AudioTypes.h>

pixelroot32::audio::AudioEvent soundEffect{};
soundEffect.type = pixelroot32::audio::WaveType::PULSE;  // Waveform type
soundEffect.frequency = 1500.0f;                  // Frequency in Hz
soundEffect.duration = 0.12f;                      // Duration in seconds
soundEffect.volume = 0.8f;                         // Volume (0.0 to 1.0)
soundEffect.duty = 0.5f;                           // Duty cycle (for PULSE only)

Wave Types

PixelRoot32 supports three wave types:

  • PULSE: Square wave with variable duty cycle
  • Duty cycles: 0.125 (thin), 0.25 (classic NES), 0.5 (symmetric), 0.75 (fat)
  • Good for: Beeps, jumps, UI sounds, leads

  • TRIANGLE: Triangle wave (fixed volume/duty)

  • Softer, smoother sound
  • Good for: Bass lines, pads, background tones

  • NOISE: Pseudo-random noise

  • Harsh, chaotic sound
  • Good for: Explosions, hits, impacts, drums

Playing Sound Effects

// Get the audio engine
auto& audio = engine.getAudioEngine();

// Create and play a sound
pixelroot32::audio::AudioEvent jumpSound{};
jumpSound.type = pixelroot32::audio::WaveType::PULSE;
jumpSound.frequency = 800.0f;
jumpSound.duration = 0.1f;
jumpSound.volume = 0.7f;
jumpSound.duty = 0.25f;

audio.playEvent(jumpSound);

Common Sound Effects

Here are some example sound effects you can use:

namespace SoundEffects {
    // Jump sound
    inline pixelroot32::audio::AudioEvent jump() {
        pixelroot32::audio::AudioEvent evt{};
        evt.type = pixelroot32::audio::WaveType::PULSE;
        evt.frequency = 600.0f;
        evt.duration = 0.1f;
        evt.volume = 0.7f;
        evt.duty = 0.25f;
        return evt;
    }

    // Coin/collect sound
    inline pixelroot32::audio::AudioEvent coin() {
        pixelroot32::audio::AudioEvent evt{};
        evt.type = pixelroot32::audio::WaveType::PULSE;
        evt.frequency = 1500.0f;
        evt.duration = 0.12f;
        evt.volume = 0.8f;
        evt.duty = 0.5f;
        return evt;
    }

    // Explosion
    inline pixelroot32::audio::AudioEvent explosion() {
        pixelroot32::audio::AudioEvent evt{};
        evt.type = pixelroot32::audio::WaveType::NOISE;
        evt.frequency = 200.0f;
        evt.duration = 0.3f;
        evt.volume = 0.9f;
        return evt;
    }

    // Hit/damage
    inline pixelroot32::audio::AudioEvent hit() {
        pixelroot32::audio::AudioEvent evt{};
        evt.type = pixelroot32::audio::WaveType::NOISE;
        evt.frequency = 300.0f;
        evt.duration = 0.15f;
        evt.volume = 0.6f;
        return evt;
    }
}

// Usage
audio.playEvent(SoundEffects::jump());

Background Music

📖 For comprehensive MusicPlayer guide with advanced examples, see MusicPlayer Integration Guide

Background music uses the MusicPlayer system, which sequences notes over time.

Music Notes

Music is built from MusicNote structures:

#include <audio/AudioMusicTypes.h>

using namespace pixelroot32::audio;

MusicNote note{};
note.note = Note::C;        // Musical note (C, D, E, F, G, A, B, or Rest)
note.octave = 4;            // Octave (0-8)
note.duration = 0.2f;       // Duration in seconds
note.volume = 0.7f;         // Volume (0.0 to 1.0)

Instrument Presets

For convenience, use predefined instrument presets:

using namespace pixelroot32::audio;

// Available presets:
// - INSTR_PULSE_LEAD: Main lead pulse (octave 4)
// - INSTR_PULSE_BASS: Bass pulse (octave 3)
// - INSTR_PULSE_CHIP_HIGH: High-pitched chiptune (octave 5)
// - INSTR_TRIANGLE_PAD: Soft triangle pad (octave 4)

Creating a Melody

#include <audio/AudioMusicTypes.h>

using namespace pixelroot32::audio;

// Define melody notes
static const MusicNote MELODY_NOTES[] = {
    makeNote(INSTR_PULSE_LEAD, Note::C, 0.20f),
    makeNote(INSTR_PULSE_LEAD, Note::E, 0.20f),
    makeNote(INSTR_PULSE_LEAD, Note::G, 0.25f),
    makeRest(0.10f),  // Rest (silence)
    makeNote(INSTR_PULSE_LEAD, Note::C, 0.20f),
    makeNote(INSTR_PULSE_LEAD, Note::E, 0.20f),
    makeNote(INSTR_PULSE_LEAD, Note::G, 0.25f),
    makeRest(0.10f),
};

// Create music track
static const MusicTrack GAME_MUSIC = {
    MELODY_NOTES,                              // notes array
    sizeof(MELODY_NOTES) / sizeof(MusicNote),  // note count
    true,                                       // loop
    WaveType::PULSE,                           // channel type
    0.5f                                       // duty cycle
};

Playing Music

// Get the music player
auto& music = engine.getMusicPlayer();

// Play a track
music.play(GAME_MUSIC);

// Control playback
music.stop();   // Stop playback
music.pause();  // Pause (time doesn't advance)
music.resume(); // Resume after pause

// Check status
if (music.isPlaying()) {
    // Music is currently playing
}

Music in Scene

Typically, you start music in your scene's init():

void MyGameScene::init() override {
    // Start background music
    engine.getMusicPlayer().play(GAME_MUSIC);

    // ... rest of initialization
}

Master Volume

Control overall volume without changing individual sounds:

auto& audio = engine.getAudioEngine();

// Set master volume (0.0 to 1.0)
audio.setMasterVolume(0.5f); // 50% volume

// Get current volume
float currentVolume = audio.getMasterVolume();

Complete Example

Here's a complete example combining sound effects and music:

#include <core/Scene.h>
#include <audio/AudioTypes.h>
#include <audio/AudioMusicTypes.h>

using namespace pixelroot32::audio;

// Background music
static const MusicNote GAME_MELODY[] = {
    makeNote(INSTR_PULSE_LEAD, Note::C, 0.20f),
    makeNote(INSTR_PULSE_LEAD, Note::E, 0.20f),
    makeNote(INSTR_PULSE_LEAD, Note::G, 0.25f),
    makeRest(0.10f),
};

static const MusicTrack BACKGROUND_MUSIC = {
    GAME_MELODY,
    sizeof(GAME_MELODY) / sizeof(MusicNote),
    true,  // loop
    WaveType::PULSE,
    0.5f
};

class AudioExampleScene : public pixelroot32::core::Scene {
public:
    void init() override {
        // Start background music
        engine.getMusicPlayer().play(BACKGROUND_MUSIC);

        // Set master volume
        engine.getAudioEngine().setMasterVolume(0.8f);
    }

    void update(unsigned long deltaTime) override {
        auto& input = engine.getInputManager();
        auto& audio = engine.getAudioEngine();

        // Play sound effect on button press
        if (input.isButtonPressed(4)) { // Button A
            AudioEvent jumpSound{};
            jumpSound.type = WaveType::PULSE;
            jumpSound.frequency = 800.0f;
            jumpSound.duration = 0.1f;
            jumpSound.volume = 0.7f;
            jumpSound.duty = 0.25f;

            audio.playEvent(jumpSound);
        }

        Scene::update(deltaTime);
    }

    void draw(pixelroot32::graphics::Renderer& renderer) override {
        Scene::draw(renderer);
    }
};

Designing NES-like Sounds

Frequency Guidelines

  • Low frequencies (200-400 Hz): Bass, impacts, explosions
  • Mid frequencies (400-1000 Hz): Main sounds, jumps, UI
  • High frequencies (1000-2000 Hz): Beeps, coins, pickups
  • Very high (2000+ Hz): Sharp sounds, alerts

Duration Guidelines

  • Short (0.05-0.1s): UI clicks, small effects
  • Medium (0.1-0.2s): Jumps, hits, pickups
  • Long (0.2-0.5s): Explosions, power-ups, transitions

Duty Cycle (PULSE only)

  • 0.125: Thin, sharp, piercing
  • 0.25: Classic NES lead sound
  • 0.5: Symmetric, full, fat
  • 0.75: Very fat, bass-like

Best Practices

Sound Design

  • Keep sounds short: Long sounds can overlap and cause issues
  • Use appropriate volumes: 0.6-0.8 is usually good for effects
  • Vary frequencies: Don't use the same frequency for everything
  • Test on hardware: ESP32 audio may sound different than PC

Music

  • Use one channel for music: Leave other channels for SFX
  • Keep melodies simple: Complex melodies can be hard to follow
  • Loop seamlessly: End your melody where it can loop naturally
  • Consider tempo: Faster games need faster music

Performance

  • Limit simultaneous sounds: Only 4 channels total
  • Music uses one channel: Plan your SFX accordingly
  • Don't spam sounds: Too many sounds can cause audio glitches
  • Use master volume: Easier than adjusting individual sounds

Common Patterns

Sound Effect Helper Function

void playJumpSound() {
    auto& audio = engine.getAudioEngine();
    AudioEvent evt{};
    evt.type = WaveType::PULSE;
    evt.frequency = 600.0f;
    evt.duration = 0.1f;
    evt.volume = 0.7f;
    evt.duty = 0.25f;
    audio.playEvent(evt);
}

Music State Management

class GameScene : public Scene {
    bool musicStarted = false;

    void init() override {
        // Don't start music here if scene can be re-initialized
    }

    void update(unsigned long deltaTime) override {
        if (!musicStarted) {
            engine.getMusicPlayer().play(GAME_MUSIC);
            musicStarted = true;
        }
        Scene::update(deltaTime);
    }
};

Troubleshooting

No Sound

  • Check audio backend is configured correctly
  • Verify sample rate matches backend
  • Check master volume is not 0
  • Ensure audio is initialized (engine.init())

Distorted Sound

  • Automatic Protection: The non-linear mixer now prevents most digital clipping automatically.
  • Check for hardware-specific issues (bad cables, poor power supply).
  • Reduce sample rate (ESP32 DAC works better at 11025 Hz).
  • Verify that you are not manually scaling volumes above 1.0.

Music Not Playing

  • Check music.isPlaying() status
  • Ensure track is properly defined
  • Verify MusicPlayer is updated (happens automatically)
  • Check that music channel is not being used by SFX

Next Steps

Now that you can add audio, learn about:


See also: