Audio System
PixelRoot32 provides a NES-like audio subsystem: four fixed channels (two pulse, one triangle, one noise), mono 16-bit output, event-driven playback (AudioEvent), and sample-accurate timing decoupled from the game frame rate. There is no DMC/sample channel in the current engine.
For implementation details, see Audio subsystem (authoritative).
Architecture Overview
AudioEngineforwards commands andgenerateSamplesto the activeAudioScheduler, which delegates toApuCore: SPSC queue, four channels, music sequencer, mixing, and (on the FPU path) a one-pole output HPF. Synthesis is not duplicated across three scheduler copies.- On ESP32, core affinity and task priority are applied when the backend creates its FreeRTOS task (
PlatformCapabilities), not insideESP32AudioSchedulerconstruction arguments (those parameters are reserved for API stability).
Key Features
| Feature | Description |
|---|---|
| 4 channels | 2× pulse (duty configurable), 1× triangle, 1× noise |
| Sample-accurate | Channel lifetime in samples; independent of FPS |
| Schedulers | NativeAudioScheduler (PC), ESP32AudioScheduler (firmware), DefaultAudioScheduler (tests / callback-driven) |
| Command path | Lock-free SPSC ring buffer, 128 entries; one producer / one consumer |
| Mixer | Soft saturation (FPU) or LUT (no-FPU, e.g. ESP32-C3) |
| Multi-track music | Up to MAX_MUSIC_TRACKS (4): main MusicTrack + optional secondVoice, thirdVoice, percussion; MusicPlayer::play packs them into AudioCommand::subTracks |
| NES-style timing | Sequencer in ApuCore uses tick steps derived from sample time and BPM (default 150 BPM, 4 ticks per beat); not tied to render FPS |
| BPM & tempo | MusicPlayer::setBPM / getBPM and setTempoFactor → MUSIC_SET_BPM / MUSIC_SET_TEMPO |
| Percussion presets | INSTR_KICK, INSTR_SNARE, INSTR_HIHAT (duty == 0, WaveType::NOISE, noisePeriod / defaultDuration in InstrumentPreset) |
Quick start: sound effects
Use AudioEngine::playEvent with an AudioEvent (#include <audio/AudioEngine.h>, <audio/AudioTypes.h>):
#if PIXELROOT32_ENABLE_AUDIO
#include <audio/AudioEngine.h>
#include <audio/AudioTypes.h>
void playCoin(pr32::core::Engine& engine) {
pr32::audio::AudioEvent evt{};
evt.type = pr32::audio::WaveType::PULSE;
evt.frequency = 1500.0f;
evt.duration = 0.12f;
evt.volume = 0.8f;
evt.duty = 0.5f;
engine.getAudioEngine().playEvent(evt);
}
#endifWave types (WaveType)
| Type | Role |
|---|---|
PULSE | Square wave; use duty (e.g. 0.125, 0.25, 0.5) |
TRIANGLE | Triangle wave; duty unused |
NOISE | See Noise channel semantics below |
Noise channel semantics
frequencyon a NOISE event does not set musical pitch inApuCore. It drives the noise clock: default period in samples issample_rate / max(frequency, 1 Hz)whennoisePeriod == 0. Lower values → coarser / more “hit-like”; higher values → denser noise. UsenoisePeriodfor fixed percussion periods.- All platforms share the same 15-bit NES-style LFSR inside
ApuCore; step rate followsnoisePeriodSamples/noiseCountdown(andAudioEvent), notrand().
Master volume
engine.getAudioEngine().setMasterVolume(0.75f);
float v = engine.getAudioEngine().getMasterVolume();Per-channel volume is set per AudioEvent::volume; there are no separate setChannelVolume APIs in the core engine.
Music (MusicPlayer)
Sequencing is sample-accurate and tick-based inside ApuCore. MusicPlayer only enqueues AudioCommands (MUSIC_PLAY, tempo/BPM, pause/resume, stop). See Music Player API and the long-form Music player guide.
Multi-track layout
Point optional MusicTrack pointers from the main track: secondVoice, thirdVoice, percussion. Each sub-track has its own notes, loop, channelType, and duty (e.g. melody on PULSE, drums on NOISE). MusicPlayer::getActiveTrackCount() returns how many layers were requested on the last play() (1–4).
Example project
The engine’s music_demo sample showcases multi-track arrangements, instrument presets, and melodies: examples/music_demo (PlatformIO). Also see tic_tac_toe / brick_breaker for lighter music use (Audio samples).
#include <audio/MusicPlayer.h>
#include <audio/AudioMusicTypes.h>
using namespace pixelroot32::audio;
static const MusicNote MELODY[] = {
makeNote(INSTR_PULSE_LEAD, Note::C, 0.20f),
makeNote(INSTR_PULSE_LEAD, Note::E, 0.20f),
makeRest(0.10f),
};
static const MusicTrack GAME_MUSIC = {
MELODY,
sizeof(MELODY) / sizeof(MusicNote),
true, // loop
WaveType::PULSE,
0.5f // duty for pulse tracks
};
void MyScene::init(pr32::core::Engine& engine) {
engine.getMusicPlayer().play(GAME_MUSIC);
}The music sequencer lives in ApuCore. If the audio consumer stops running for a long time (debugger, host suspend), the next generateSamples may advance many ticks in one block (more CPU in that step; there is no fixed per-quantum note cap).
Command queue and thread safety
- Only one thread should call
playEvent,setMasterVolume,MusicPlayer, andsubmitCommand(SPSC contract). - If the queue is full, the newest command is dropped.
ApuCoreincrements an atomic drop counter and may emit a throttled warning whenPIXELROOT32_DEBUG_MODEis defined. Avoid enqueue storms without the audio thread draining the queue.
Audio configuration
Backends are concrete objects passed by pointer on AudioConfig, not an enum:
#include <audio/AudioConfig.h>
#include <drivers/esp32/ESP32_I2S_AudioBackend.h>
pr32::drivers::esp32::ESP32_I2S_AudioBackend audioBackend(26, 25, 22, 22050);
pr32::audio::AudioConfig audioConfig;
audioConfig.backend = &audioBackend;
audioConfig.sampleRate = 22050;
pr32::core::Engine engine(displayConfig, inputConfig, audioConfig);See AudioEngine for AudioConfig fields and architecture links.
Platform differences
| Platform | Mixer | Noise (typical) | Audio execution |
|---|---|---|---|
| ESP32 (FPU) | Float + soft clip | Clocked LFSR | Backend task; core from PlatformCapabilities |
| ESP32-C3 (no FPU) | LUT | Clocked LFSR | Same; integer LUT mix |
| PC (native) | Float + soft clip + HPF | Same LFSR as firmware | std::thread + ring buffer → SDL2 callback |
Best practices
- Keep SFX
durationshort when possible; only four logical channels exist, with voice stealing among channels of the sameWaveType. - Use NOISE
frequency/noisePeriodto shape percussion vs hiss (see above); for authored drums preferINSTR_*presets on apercussionsub-track. - Do not rely on multiple threads calling
playEventwithout a different queue design. - Test on real hardware; buffer sizes and backend (I2S vs DAC) affect latency.
Next steps
- Audio architecture — Subsystem narrative (kept in sync with the engine)
- AudioEngine & types — Methods,
AudioEvent,AudioCommand,AudioConfig, music transport queries - AudioScheduler — Schedulers vs
ApuCore - MusicPlayer — Tracks, presets, tempo/BPM
- Engine source:
ApuCore.h/ApuCore.cpp— authoritative implementation
