Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SoundEngine

Short WAV sound effects on an ESP32, played through an I2S DAC.

Clips live in flash as byte arrays. The engine mixes any number of them together and streams the result to the I2S peripheral from its own background task, so play() returns immediately and playback never blocks the sketch. Each clip carries its own volume and can be set to repeat; a master gain sits on top of all of them.

#include <SoundEngine.h>

SoundEngine sound(21, 14, 13);   // LRCLK, BCLK, DOUT

void setup()
{
  sound.begin();
  sound.setVolume(100);
}

void loop()
{
  sound.play(sound.beep());
  delay(2000);
}

Requirements

  • An ESP32. The I2S code uses the i2s_std API, which means Arduino-ESP32 core 3.x (ESP-IDF 5.x). It will not compile against core 2.x, where the header is driver/i2s.h.

  • An I2S DAC or amplifier — MAX98357A, PCM5102, UDA1334A and similar all work. Three pins:

    signal meaning
    LRCLK left/right clock, also called word select or frame clock
    BCLK bit clock
    DOUT data out, ESP32 to DAC

No other library is needed.

Installing

Library Manager — in the Arduino IDE, Sketch → Include Library → Manage Libraries, search for SoundEngine, install.

From this repositoryCode → Download ZIP, then Sketch → Include Library → Add .ZIP Library. Use this if you want a version that has not been released yet.

Examples

PlaySounds — plays each of the six clips in turn, one at a time.

MixAndLoop — several clips looping together, layered on and removed one at a time.

PlayTones — generated notes and melodies, with no WAV file involved.

ArcadeLoop — a two-part looping soundtrack with sound effects mixed over the top, all generated.

PlayFromFile — WAV files read from LittleFS into PSRAM at start-up.

Sounds

Three clips are built in and need no include:

sound.play(sound.click());
sound.play(sound.beep());
sound.play(sound.signal());

Three more ship with the library and are included when you want them:

#include <sounds/Startup.h>
AudioClip startupClip(cSoundStartupWAV);

sound.play(startupClip);

They cost no flash unless a sketch includes them.

Adding your own

A clip is 16-bit PCM WAV data — mono or stereo, any sample rate; the engine resamples to 44.1 kHz as it plays. Convert your file to a hex byte array and drop it in a header alongside the others:

  1. to WAV, if it is not already — for example https://convertio.co/mp3-wav/
  2. to a hex array — for example http://tomeko.net/online_tools/file_to_hex.php?lang=en

The array is what AudioClip reads:

const uint8_t cMySoundWAV[] PROGMEM = { 0x52, 0x49, 0x46, 0x46, ... };

An AudioClip does not copy those bytes, so the array has to outlive every play(). File scope is the simplest way to be sure of that. A file that is not RIFF/WAVE, or not 16-bit PCM, is reported once on the serial port and then plays silently rather than making noise.

Playing several at once

Clips mix rather than replace, so play() on more than one leaves them all running. A second argument repeats a clip until it is stopped:

sound.play(sound.signal(), true);   // loops
sound.play(sound.beep(), true);     // joins it, both now playing
sound.stop(sound.beep());           // the other carries on

The mixer sums the clips and clamps the total, so several at full volume will run into that ceiling and distort. Share the budget out with per-clip volumes — see MixAndLoop.

Tones and melodies

Sound without a WAV file. A ToneClip is one generated note; a MelodyClip plays a run of them from an array. Both are ordinary clips, so they mix, repeat and take a per-clip volume:

ToneClip   tone;
MelodyClip melody;

sound.playTone(tone, cNoteA4, 200);                        // A4, 200 ms, sine
sound.playTone(tone, 1200.0f, 80, Waveform::eSquare);      // any frequency you like

const Note cStartup[] = { { cNoteC5, 90 }, { cNoteE5, 90 }, { cNoteG5, 180 } };
sound.playMelody(melody, cStartup, 3);

Four waveforms: eSine, eSquare, eTriangle, eSawtooth. Note constants run cNoteC4 to cNoteC6cNoteA4 is 440 Hz — and cNoteRest is a silent gap of the given length.

Arm the clips through playTone() and playMelody() rather than calling setTone() or setMelody() yourself: those rewrite state the mixer may be part-way through reading, and the engine's versions take its mutex first. A melody's note array is not copied, so it has to outlive the playing — file scope is the simplest guarantee.

Two things about how they sound. Every note is faded in and out over about 5 ms, because a waveform that starts or stops away from zero clicks — most audibly on square and sawtooth. And tones are generated at half scale, on the assumption that they will be mixed with something; turn a clip up with setVolume() if it is playing alone.

Playing files from the filesystem

A FileClip reads a WAV off any filesystem — LittleFS, FFat, SD — into memory once, and is an ordinary clip from then on:

FileClip alarm;                                  // file scope: it must outlive every play()

sound.load(alarm, LittleFS, "/alarm.wav");       // slow, do it in setup()
sound.play(alarm);                               // costs no more than a built-in clip

PSRAM is required. Internal RAM is around 200 kB with everything else running and a second of 44.1 kHz stereo is 176 kB, so quietly spending it would break something else instead — load() refuses and says so. Set cRequirePSRAM in FileClip.h to false to allow the internal heap.

The file is read in full, not streamed. Filesystem reads block, and the buffer-filler task has to produce a chunk every 2.9 ms, so a read inside it is how a dropout happens. Loading up front moves that cost to start-up, where a pause does not matter.

Use sound.load() and sound.unload() rather than the FileClip methods directly: they stop the clip first, which the clip cannot do for itself, and freeing memory the mixer is reading is not survivable.

LittleFS is the sensible choice on internal flash — it is in the core, wear-levelled, survives a power cut mid-write, and reads far faster than audio needs. FFat is quicker for long sequential reads but less robust; SPIFFS is deprecated.

How long before you hear it

play() is not instant, and the filesystem is not why. The I2S DMA holds dma_desc_num × dma_frame_num frames — 8 × 256 by default, which is 2048 frames, or 46 ms at 44.1 kHz. A clip is mixed into the next 2.9 ms chunk and then queues behind whatever is already in the DMA. Those two constants in AudioPlayer::begin() are the lever if button feedback needs to feel tighter; shrinking them leaves the filler task, which runs at tskIDLE_PRIORITY + 1, less room to fall behind before it is audible.

Volume

Two independent gains, both percentages, both clamped to 0–200. 100 is the clip's own recorded level and 200 is twice as loud:

sound.setVolume(80);              // master, applies to everything
sound.setVolume(errorClip, 150);  // this clip only

Neither is persisted; both return to their defaults on every boot.

Threads

The buffer is filled by a FreeRTOS task the engine starts in begin(), so anything touching a clip the mixer might be reading has to go through SoundEngine, which takes its mutex. In particular do not call AudioClip::setVolume() or setRepeatForever() directly on a clip that may be playing — use SoundEngine::setVolume(clip, volume) instead.

play(), stop(), stopAll() and isPlaying() are all safe to call from loop() at any time.

Credits

The I2S driver follows the approach of the XTronical audio driver, https://www.xtronical.com/i2sprerelease.

Licence

MIT. See LICENSE.

About

Short WAV sound effects on an ESP32, played through an I2S DAC.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages