MusicPlayer Integration Guide - PixelRoot32
Overview
The MusicPlayer class provides a simple yet powerful way to add background music and melodies to your PixelRoot32 games. It integrates seamlessly with the NES-inspired audio stack and supports tempo control, looping, dynamic music switching, and multi-track layering (secondVoice, thirdVoice, percussion) so you can spell out arpeggiated figures with ordinary MusicNote data.
Modular Compilation: The MusicPlayer is only compiled when PIXELROOT32_ENABLE_AUDIO=1. When disabled, all music-related functionality is excluded from the build, saving both firmware size and RAM usage. MusicTrack::channelType supports PULSE, TRIANGLE, NOISE, SINE, and SAW.
Voice pool vs. sequencer tracks: MusicPlayer can arrange up to MAX_MUSIC_TRACKS (4) logical layers (main + sub-tracks), but every note still becomes a PLAY_EVENT inside ApuCore, which mixes at most ApuCore::MAX_VOICES (8) simultaneous voices. Dense chords, fast arps, plus heavy SFX can exceed eight concurrent notes and trigger voice stealing (shortest remaining note is replaced). Author shorter note lengths or fewer simultaneous layers if you need deterministic timbres on hardware.
This guide covers everything from basic music playback to advanced patterns like adaptive soundtracks and smooth transitions.
Quick Start
Basic Music Playback
#include "audio/MusicPlayer.h"
#include "audio/AudioMusicTypes.h"
#include "audio/AudioTypes.h"
using namespace pixelroot32::audio;
// Define your melody
static const MusicNote SIMPLE_MELODY[] = {
makeNote(INSTR_PULSE_LEAD, Note::C, 4, 0.25f), // C4 quarter note
makeNote(INSTR_PULSE_LEAD, Note::E, 4, 0.25f), // E4 quarter note
makeNote(INSTR_PULSE_LEAD, Note::G, 4, 0.25f), // G4 quarter note
makeNote(INSTR_PULSE_LEAD, Note::C, 5, 0.5f), // C5 half note
};
static const MusicTrack SIMPLE_TRACK = {
SIMPLE_MELODY,
sizeof(SIMPLE_MELODY) / sizeof(MusicNote),
true, // Loop enabled
WaveType::PULSE, // Use pulse wave
0.5f // 50% duty cycle
};
// In your scene's init() method
void MyScene::init() {
#if PIXELROOT32_ENABLE_AUDIO
// Get music player from engine
auto& musicPlayer = engine.getMusicPlayer();
// Start playing track
musicPlayer.play(SIMPLE_TRACK);
#endif
}Music Track Structure
MusicTrack Definition
struct MusicTrack {
const MusicNote* notes; // Array of notes
size_t count; // Number of notes
bool loop; // Whether to loop the track
WaveType channelType; // PULSE, TRIANGLE, NOISE, SINE, SAW
float duty; // Duty cycle for pulse waves (0.0-1.0)
// Multi-track support (optional)
const MusicTrack* secondVoice = nullptr; // Second melody voice
const MusicTrack* thirdVoice = nullptr; // Third melody voice
const MusicTrack* percussion = nullptr; // Drum/percussion track
};Multi-Track Music Playback
The MusicPlayer supports up to 4 simultaneous tracks playing in parallel:
// Define three separate tracks
static const MusicNote MELODY_NOTES[] = {
makeNote(INSTR_PULSE_LEAD, Note::C, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::E, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::G, 4, 0.25f),
};
static const MusicNote BASS_NOTES[] = {
makeNote(INSTR_TRIANGLE_BASS, Note::C, 2, 0.5f),
makeNote(INSTR_TRIANGLE_BASS, Note::G, 2, 0.5f),
};
static const MusicNote DRUM_NOTES[] = {
makeNote(INSTR_KICK, Note::Rest, 0.25f),
makeRest(0.25f),
makeNote(INSTR_SNARE, Note::Rest, 0.25f),
makeRest(0.25f),
};
// Create individual tracks
static const MusicTrack MELODY_TRACK = {
MELODY_NOTES, 3, true, WaveType::PULSE, 0.5f
};
static const MusicTrack BASS_TRACK = {
BASS_NOTES, 2, true, WaveType::TRIANGLE, 0.5f
};
static const MusicTrack DRUM_TRACK = {
DRUM_NOTES, 4, true, WaveType::NOISE, 0.0f
};
// Combine into main track with sub-tracks
static const MusicTrack FULL_MUSIC = {
MELODY_NOTES, 3, true, WaveType::PULSE, 0.5f,
&BASS_TRACK, // secondVoice - bass line
nullptr, // thirdVoice - not used
&DRUM_TRACK // percussion - drums
};
// Play all 3 tracks simultaneously
musicPlayer.play(FULL_MUSIC);
// Query active track count
size_t count = musicPlayer.getActiveTrackCount(); // Returns 3Note: All sub-track pointers default to
nullptrfor backward compatibility with existing single-track code.
Manual arpeggios (extra voice)
There is no separate arpeggiator API. To get a rapid broken-chord line under a lead, add a MusicTrack hooked via secondVoice or thirdVoice whose MusicNote entries use short duration values (in beats, same grid as the rest of the sequencer). You can use WaveType::SINE / SAW directly on that layer. See Melody 4 in examples/music_demo/src/assets/melodies.h.
MusicNote Definition
struct MusicNote {
Note note; // Musical note (C, D, E, etc.)
uint8_t octave; // Octave number (0-8). For percussion: 1=Kick, 2=Snare, 3+=Hi-HAT
float duration; // Duration in seconds
float volume; // Volume (0.0-1.0)
const InstrumentPreset* preset; // Optional: pointer to instrument preset for percussion
};Creating Music
Note Helper Functions
The engine provides convenient helper functions for creating notes:
// Basic note with default octave (4)
makeNote(INSTR_PULSE_LEAD, Note::C, 0.25f);
// Note with specific octave
makeNote(INSTR_PULSE_LEAD, Note::C, 5, 0.25f);
// Rest (silence)
makeRest(0.5f);
// Using predefined instruments
static const InstrumentPreset INSTR_PULSE_LEAD = {
0.8f, // Base volume
0.5f // Duty cycle
};Musical Notes and Octaves
enum class Note : uint8_t {
C = 0, Cs, D, Ds, E, F, Fs, G, Gs, A, As, B, Rest, COUNT
};
// Octave 4 is middle C (C4)
// Standard range: Octave 0-8
// A4 = 440 Hz (concert pitch)Frequency Reference (Octave 4)
| Note | Frequency (Hz) |
|---|---|
| C4 | 261.63 |
| D4 | 293.66 |
| E4 | 329.63 |
| F4 | 349.23 |
| G4 | 392.00 |
| A4 | 440.00 |
| B4 | 493.88 |
Advanced Music Patterns
1. Tempo Control
#if PIXELROOT32_ENABLE_AUDIO
class AdaptiveMusicScene : public Scene {
private:
float currentTempo = 1.0f;
public:
void update(unsigned long deltaTime) override {
auto& musicPlayer = engine.getMusicPlayer();
// Speed up music as score increases
if (score > 1000 && currentTempo < 1.5f) {
currentTempo += 0.01f;
musicPlayer.setTempoFactor(currentTempo);
}
// Slow down when player is low on health
if (playerHealth < 25 && currentTempo > 0.7f) {
currentTempo -= 0.02f;
musicPlayer.setTempoFactor(currentTempo);
}
}
};
#endif2. Dynamic Music Switching
void GameScene::switchToBattleMusic() {
auto& musicPlayer = engine.getMusicPlayer();
// Fade out current music (gradually reduce tempo)
for (int i = 0; i < 10; i++) {
float factor = musicPlayer.getTempoFactor() * 0.9f;
musicPlayer.setTempoFactor(factor);
delay(50); // 50ms delay for smooth transition
}
// Switch to battle music
musicPlayer.stop();
musicPlayer.setTempoFactor(1.2f); // Slightly faster
musicPlayer.play(BATTLE_MUSIC);
}3. Layered Music System
// Background layer (bass line)
static const MusicNote BASS_LINE[] = {
makeNote(INSTR_TRIANGLE_BASS, Note::C, 2, 0.5f),
makeNote(INSTR_TRIANGLE_BASS, Note::G, 2, 0.5f),
makeNote(INSTR_TRIANGLE_BASS, Note::A, 2, 0.5f),
makeNote(INSTR_TRIANGLE_BASS, Note::F, 2, 0.5f),
};
// Melody layer (lead)
static const MusicNote MELODY[] = {
makeNote(INSTR_PULSE_LEAD, Note::C, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::E, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::G, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::C, 5, 0.25f),
};
// Use different wave types for variety
static const MusicTrack BASS_TRACK = {
BASS_LINE, sizeof(BASS_LINE)/sizeof(MusicNote), true, WaveType::TRIANGLE, 0.5f
};
static const MusicTrack MELODY_TRACK = {
MELODY, sizeof(MELODY)/sizeof(MusicNote), true, WaveType::PULSE, 0.75f
};4. Adaptive Soundtrack
class AdaptiveMusic {
private:
enum class MusicState {
EXPLORATION,
COMBAT,
VICTORY,
DEFEAT
};
MusicState currentState = MusicState::EXPLORATION;
MusicState targetState = MusicState::EXPLORATION;
public:
void updateMusic(float threatLevel, bool inCombat, bool victory) {
auto& musicPlayer = engine.getMusicPlayer();
// Determine target state
if (victory) {
targetState = MusicState::VICTORY;
} else if (threatLevel > 0.7f) {
targetState = MusicState::COMBAT;
} else if (inCombat) {
targetState = MusicState::COMBAT;
} else {
targetState = MusicState::EXPLORATION;
}
// Handle state transitions
if (currentState != targetState) {
switch (targetState) {
case MusicState::COMBAT:
musicPlayer.setTempoFactor(1.3f);
if (currentState != MusicState::COMBAT) {
musicPlayer.play(COMBAT_MUSIC);
}
break;
case MusicState::VICTORY:
musicPlayer.stop();
musicPlayer.setTempoFactor(1.0f);
musicPlayer.play(VICTORY_MUSIC);
break;
case MusicState::EXPLORATION:
musicPlayer.setTempoFactor(0.9f);
if (currentState == MusicState::COMBAT) {
musicPlayer.play(EXPLORATION_MUSIC);
}
break;
}
currentState = targetState;
}
}
};Integration Patterns
1. Scene-Based Music Management
class GameScene : public Scene {
private:
MusicState currentMusic = MusicState::MENU;
public:
void init() override {
// Start with menu music
engine.getMusicPlayer().play(MENU_MUSIC);
engine.getMusicPlayer().setTempoFactor(1.0f);
}
void onSceneEnter() override {
// Resume music when returning to this scene
if (!engine.getMusicPlayer().isPlaying()) {
engine.getMusicPlayer().resume();
}
}
void onSceneExit() override {
// Pause music when leaving scene
engine.getMusicPlayer().pause();
}
void reset() override {
// Stop and restart music
engine.getMusicPlayer().stop();
engine.getMusicPlayer().play(MENU_MUSIC);
}
};2. Music with Sound Effects Mixing
void GameScene::playAttackSound() {
// Lower music volume slightly during SFX
auto& musicPlayer = engine.getMusicPlayer();
float originalTempo = musicPlayer.getTempoFactor();
// Slight tempo reduction for dramatic effect
musicPlayer.setTempoFactor(originalTempo * 0.95f);
// Play attack sound effect (AudioEvent: type, frequency, duration, volume, duty, ...)
AudioEvent hit{};
hit.type = WaveType::NOISE;
hit.frequency = 200.0f; // noise clock / density
hit.duration = 0.1f;
hit.volume = 0.8f;
hit.duty = 0.5f;
engine.getAudioEngine().playEvent(hit);
// Restore tempo after delay
delay(100);
musicPlayer.setTempoFactor(originalTempo);
}3. Music Synchronization with Gameplay
void RhythmGameScene::update(unsigned long deltaTime) {
auto& musicPlayer = engine.getMusicPlayer();
// Check if we're on a beat (every 0.5 seconds)
static float beatTimer = 0.0f;
beatTimer += deltaTime * 0.001f;
if (beatTimer >= 0.5f) {
beatTimer = 0.0f;
// Spawn enemy on beat
spawnEnemy();
// Flash screen on beat
screenFlash = 255;
}
// Update screen flash
if (screenFlash > 0) {
screenFlash -= 5;
}
}Common Music Patterns
8-Bar Blues Progression
static const MusicNote BLUES_PROGRESSION[] = {
// Bar 1-2: C7
makeNote(INSTR_PULSE_LEAD, Note::C, 4, 1.0f),
makeNote(INSTR_PULSE_LEAD, Note::E, 4, 1.0f),
makeNote(INSTR_PULSE_LEAD, Note::G, 4, 1.0f),
makeNote(INSTR_PULSE_LEAD, Note::Bb, 4, 1.0f),
// Bar 3-4: C7
makeNote(INSTR_PULSE_LEAD, Note::C, 4, 1.0f),
makeNote(INSTR_PULSE_LEAD, Note::E, 4, 1.0f),
makeNote(INSTR_PULSE_LEAD, Note::G, 4, 1.0f),
makeNote(INSTR_PULSE_LEAD, Note::Bb, 4, 1.0f),
// Continue with F7, G7, etc.
};Broken chord / arpeggiated bass (manual)
static const MusicNote ARPEGGIATED_BASS[] = {
makeNote(INSTR_TRIANGLE_BASS, Note::C, 4, 0.25f),
makeNote(INSTR_TRIANGLE_BASS, Note::E, 4, 0.25f),
makeNote(INSTR_TRIANGLE_BASS, Note::G, 4, 0.25f),
makeNote(INSTR_TRIANGLE_BASS, Note::C, 5, 0.25f),
makeNote(INSTR_TRIANGLE_BASS, Note::G, 4, 0.25f),
makeNote(INSTR_TRIANGLE_BASS, Note::E, 4, 0.25f),
makeNote(INSTR_TRIANGLE_BASS, Note::C, 4, 0.25f),
makeRest(0.25f),
};Percussive Rhythm
// Use INSTR_KICK, INSTR_SNARE, INSTR_HIHAT presets with WaveType::NOISE
static const MusicNote DRUM_PATTERN[] = {
makeNote(INSTR_KICK, Note::Rest, 0.25f), // Kick on beat 1
makeRest(0.25f),
makeNote(INSTR_SNARE, Note::Rest, 0.25f), // Snare on beat 2
makeRest(0.25f),
makeNote(INSTR_KICK, Note::Rest, 0.125f), // Kick (eighth note)
makeNote(INSTR_HIHAT, Note::Rest, 0.125f), // Hi-HAT (eighth note)
makeRest(0.25f),
makeNote(INSTR_SNARE, Note::Rest, 0.25f), // Snare on beat 4
makeRest(0.125f),
makeNote(INSTR_HIHAT, Note::Rest, 0.125f), // Hi-HAT
};Best Practices
1. Memory Efficiency
- Define music tracks as
static constto store in flash memory - Use fixed-size arrays instead of dynamic allocation
- Pre-calculate note frequencies at compile time when possible
2. Performance Optimization
- Keep music tracks reasonably short (under 100 notes)
- Use looping instead of very long sequences
- Consider using different wave types for variety vs. complexity
- Remember the
ApuCore::MAX_VOICES(8) cap: overlapping long notes across multi-track layers and SFX can cause voice stealing; shorten releases or stagger hits if you hear notes cutting off unexpectedly
3. User Experience
- Provide volume controls in your game's settings
- Allow players to disable music separately from sound effects
- Use smooth transitions between music states
- Consider platform limitations (ESP32 vs PC)
4. Platform Considerations
ESP32:
- Music timing is sample-accurate inside
ApuCore(shared by all schedulers). The backend’s audio task or I2S callback callsAudioEngine::generateSamples, which advances the sequencer and mixes PCM. - Limited memory: keep tracks short and efficient; prefer
static constdata in flash. - Subsystem is compiled only when
PIXELROOT32_ENABLE_AUDIO=1.
Native (PC/Mac/Linux):
NativeAudioSchedulerrunsApuCorein a dedicatedstd::threadand double-buffers PCM for the SDL2 callback—same synthesis and music logic as ESP32.- More headroom for longer tracks; mixing path uses the same non-linear curve as ESP32 (FPU).
isPlaying() and transport state
MusicPlayer::isPlaying() reflects whether music is actively being sequenced, not only a client-side flag:
- The authoritative signal is
AudioEngine::isMusicPlaying()/isMusicPaused(), which read atomics updated byApuCorewhenMUSIC_PLAY/MUSIC_STOP/ end-of-non-looping-track / pause / resume are processed. - While paused,
isPlaying()returns false (playback is suspended). - Right after
play(), before the audio thread has dequeuedMUSIC_PLAY,isPlaying()may still return true briefly so game code does not see a spurious “stopped” window (command in flight). - Non-looping tracks: when the last note finishes,
ApuCoreclears the music-playing flag;isPlaying()becomes false without an explicitstop().
For raw transport without the MusicPlayer wrapper, call engine.getAudioEngine().isMusicPlaying() / isMusicPaused().
BPM API
Besides setTempoFactor / getTempoFactor, you can drive absolute tempo with setBPM / getBPM (default 150 BPM, 4 ticks per beat in the sequencer).
Master Volume Control
Control the global master volume directly from MusicPlayer (delegates to AudioEngine):
// Set master volume to 50%
engine.getMusicPlayer().setMasterVolume(0.5f);
// Get current volume
float currentVolume = engine.getMusicPlayer().getMasterVolume();
// Fade music out gradually
for (float v = 1.0f; v >= 0.0f; v -= 0.05f) {
engine.getMusicPlayer().setMasterVolume(v);
delay(50);
}setMasterVolume(float volume): Sets global volume (0.0 = silent, 1.0 = full)getMasterVolume() const: Returns current global volume
This affects both music and sound effects globally (same as calling AudioEngine::setMasterVolume directly).
Master bitcrush and post-mix hook
For global lo-fi degradation or analysis, use AudioEngine::setMasterBitcrush / getMasterBitcrush (0–15; 0 = off). For custom processing on the final mono buffer (after bitcrush), configure AudioConfig::postMixMono / postMixUser when constructing the engine—see audio.md.
Related API (sweeps and extra waves)
One-shot frequency sweeps on AudioEvent (sweepEndHz, sweepDurationSec) apply to PULSE and TRIANGLE (and to SINE / SAW when extra waves are enabled). NOISE ignores sweep fields. Full detail: audio.md.
Troubleshooting
Music Not Playing
// Check if music player is working
auto& musicPlayer = engine.getMusicPlayer();
if (!musicPlayer.isPlaying()) {
Serial.println("Music player not playing");
// Check track definition
if (track.count == 0) {
Serial.println("Track has no notes");
}
// Check audio engine
if (!engine.getAudioEngine().isInitialized()) {
Serial.println("Audio engine not initialized");
}
}Tempo Issues
// Verify tempo factor
float tempo = musicPlayer.getTempoFactor();
Serial.print("Current tempo factor: ");
Serial.println(tempo);
// Reset to normal if needed
if (tempo < 0.1f || tempo > 3.0f) {
musicPlayer.setTempoFactor(1.0f);
}Memory Issues on ESP32
// Monitor free memory
Serial.print("Free heap: ");
Serial.println(ESP.getFreeHeap());
// Check track size
Serial.print("Track size: ");
Serial.println(sizeof(MY_TRACK) / sizeof(MusicNote));Complete Example: Game with Multiple Music Tracks
#include "audio/MusicPlayer.h"
#include "audio/AudioMusicTypes.h"
#include "audio/AudioTypes.h"
using namespace pixelroot32::audio;
class GameScene : public Scene {
private:
// Menu music - calm and inviting
static const MusicNote MENU_NOTES[] = {
makeNote(INSTR_PULSE_LEAD, Note::C, 4, 0.5f),
makeNote(INSTR_PULSE_LEAD, Note::E, 4, 0.5f),
makeNote(INSTR_PULSE_LEAD, Note::G, 4, 0.5f),
makeNote(INSTR_PULSE_LEAD, Note::C, 5, 1.0f),
};
static const MusicTrack MENU_TRACK = {
MENU_NOTES, sizeof(MENU_NOTES)/sizeof(MusicNote),
true, WaveType::PULSE, 0.5f
};
// Game music - upbeat and energetic
static const MusicNote GAME_NOTES[] = {
makeNote(INSTR_PULSE_LEAD, Note::C, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::D, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::E, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::F, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::G, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::A, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::B, 4, 0.25f),
makeNote(INSTR_PULSE_LEAD, Note::C, 5, 0.5f),
};
static const MusicTrack GAME_TRACK = {
GAME_NOTES, sizeof(GAME_NOTES)/sizeof(MusicNote),
true, WaveType::PULSE, 0.5f
};
// Victory music - triumphant
static const MusicNote VICTORY_NOTES[] = {
makeNote(INSTR_PULSE_LEAD, Note::C, 5, 0.125f),
makeNote(INSTR_PULSE_LEAD, Note::E, 5, 0.125f),
makeNote(INSTR_PULSE_LEAD, Note::G, 5, 0.125f),
makeNote(INSTR_PULSE_LEAD, Note::C, 6, 0.5f),
};
static const MusicTrack VICTORY_TRACK = {
VICTORY_NOTES, sizeof(VICTORY_NOTES)/sizeof(MusicNote),
false, WaveType::PULSE, 0.75f
};
enum class GameState {
MENU,
PLAYING,
VICTORY
};
GameState currentState = GameState::MENU;
public:
void init() override {
// Start with menu music
engine.getMusicPlayer().play(MENU_TRACK);
engine.getMusicPlayer().setTempoFactor(1.0f);
}
void update(unsigned long deltaTime) override {
// Handle state transitions
if (playerWon && currentState != GameState::VICTORY) {
changeState(GameState::VICTORY);
} else if (gameStarted && currentState == GameState::MENU) {
changeState(GameState::PLAYING);
}
// Update game logic...
}
private:
void changeState(GameState newState) {
auto& musicPlayer = engine.getMusicPlayer();
switch (newState) {
case GameState::MENU:
musicPlayer.stop();
musicPlayer.setTempoFactor(1.0f);
musicPlayer.play(MENU_TRACK);
break;
case GameState::PLAYING:
musicPlayer.stop();
musicPlayer.setTempoFactor(1.1f); // Slightly faster
musicPlayer.play(GAME_TRACK);
break;
case GameState::VICTORY:
musicPlayer.stop();
musicPlayer.setTempoFactor(1.0f);
musicPlayer.play(VICTORY_TRACK);
break;
}
currentState = newState;
}
};References
- Audio types & presets:
include/audio/AudioMusicTypes.h - Wave types,
AudioEvent,AudioCommand:include/audio/AudioTypes.h - MusicPlayer API:
include/audio/MusicPlayer.h(play,stop, tempo/BPM, …) - Audio facade & music transport:
include/audio/AudioEngine.h(isMusicPlaying,isMusicPaused,setMasterBitcrush, …) - Engine config & post-mix:
include/audio/AudioConfig.h - Shared synthesis, voice pool & sequencer:
include/audio/ApuCore.h(MAX_VOICES,NUM_CHANNELSalias) - API reference (sweep, bitcrush, SINE/SAW, hooks): API Audio
- Examples: See game samples under
examples/for real-world usage.
Note: Music timing is sample-accurate inside ApuCore and is independent of render frame rate, so melody tempo does not slow down when the main loop stalls (within the limits of the audio backend’s buffer).
