Skip to content
Andrew Brown edited this page Aug 31, 2026 · 8 revisions

M16 Library API Reference

A 16-bit audio synthesis library for ESP8266, ESP32, RP2040/RP2350 (Pico), and Teensy 4.x using I2S DACs and ADCs.


Quick Start

#include "M16.h"
#include "Osc.h"

Osc osc;

void setup() {
  Serial.begin(115200);
  setIsDualCore(false);
  osc.sinGen();
  osc.setPitch(69); // A4
  audioStart();
}

void loop() {}

void audioUpdate() {
  int16_t sample = osc.next();
  audioBlockWrite(sample, sample);
}

Every M16 audio sketch must include M16.h, call audioStart() in setup(), and implement audioUpdate() which calls audioBlockWrite(left, right) exactly once per generated frame.


Core (M16.h)

Audio System

Function Description
audioStart() Initialize I2S and start audio tasks. Call once in setup().
audioBlockWrite(left, right) Submit a stereo frame. Use in all new sketches.
setIsDualCore(bool) false = dedicated audio core (default, recommended for serial chains). true = block-partitioned dual-core for independent voice arrays. Must be called before audioStart().
setSampleRate(int) Set sample rate in Hz (default 44100). Call before audioStart() and before pitch/frequency setup. Teensy Audio remains fixed at 44.1 kHz.
seti2sPins(bck, ws, dout, din) Configure I2S pins before audioStart() on ESP32 and Pico. ESP8266 and Teensy routing is fixed.
useInternalDAC() Request the 8-bit internal DAC on chips that provide one. Unsupported ESP32 variants fall back to external I2S.
audioInputStart() Start the separate I2S input path on Pico and Teensy. ESP32 input starts with audioStart() when DIN is not -1; ESP8266 input is not implemented by M16.

Platform Behaviour

Platform Default audio execution Important limitation
ESP8266 Single-core callback Fixed pins, limited RAM; M16 input is not implemented.
ESP32 family One dedicated audio task by default Independent voice arrays may opt into dual-core partitioning. Internal DAC exists only on chips whose ESP-IDF reports DAC support.
Pico / Pico 2 Dedicated Core 1 by default Independent voices may opt into partitioning; original Pico needs audioLoop() to service cooperative partition jobs.
Teensy 4.x PJRC AudioStream blocks on one Cortex-M7 core Fixed 44.1 kHz PJRC I2S routing; setIsDualCore() and seti2sPins() are compatibility no-ops.

Dual-Core Partition API

For polyphonic sketches with independent voice arrays:

void audioUpdate() {
  int32_t mix = 0;
  for (int i = audioPartitionOffset(); i < voiceCount; i += audioPartitionStride()) {
    mix += voices[i].next();
  }
  audioBlockWrite(mix, mix);
}
Function Description
audioPartitionOffset() First voice index for this core.
audioPartitionStride() Step between voices (cores interleave).
audioIsFinalizerCore() True on the post-processing core (Core 0 on ESP32, Core 1 on RP2040).
setAudioPostProcessCallback(fn) Register a master effects callback that runs on the combined mix once per frame.

The callback signature is void fn(int32_t& left, int32_t& right). Put shared master reverb, delay, chorus, filtering, compression, or final gain there; per-voice effects with one instance per voice remain inside the partition loop.

ESP32 partition diagnostics include audioBlockSyncTimeoutCount(), audioBlockConsecutiveSyncTimeoutCount(), audioBlockMaxConsecutiveSyncTimeoutCount(), audioBlockLateProducerRecoveryCount(), audioDmaWriteTimeoutCount(), and the producer/DMA starvation-yield counters. Pico-family diagnostics include picoAudioBlockFallbackCount(), picoAudioBlockWorkerClaimCount(), picoAudioBlockWriteErrorCount(), and picoAudioBlockWorkerIsAutomatic().

Pico Family

Function Description
audioLoop() Service cooperative partition jobs on the original Pico when setIsDualCore(true) is used. Harmless in dedicated mode and a no-op with Pico 2's automatic worker.

Utilities

Function Description
mtof(midi) MIDI note to frequency (Hz).
ftom(freq) Frequency (Hz) to MIDI note.
clip16(val) Clamp to 16-bit signed range.
panLeft(pos) Constant-power left gain. 0.0 = full left, 1.0 = full right.
panRight(pos) Constant-power right gain.
bpmToMs(bpm) BPM to milliseconds per beat.
rand(max) Fast random integer 0 to max-1.
audioRand(max) ISR-safe random integer.
audioFrameCount() Global audio frame counter (advances once per output frame).

Osc.h — Wavetable Oscillator

Band-limited oscillator with FM, morphing, spread, and phase modulation.

Generating a Table

Osc osc;
osc.sinGen();          // Allocate and fill sine table
osc.sawGen();          // Band-limited sawtooth
osc.sqrGen();          // Band-limited square
osc.triGen();          // Band-limited triangle
osc.noiseGen();        // White noise
osc.brownNoiseGen();   // Brown noise
osc.pinkNoiseGen();    // Pink noise
osc.crackleGen();      // Sparse impulses
osc.pulseGen(0.3);     // Band-limited pulse, duty 0.0-1.0

Sharing Tables

WaveTable sharedSine;
Osc carrier;
Osc modulator;

void setup() {
  sharedSine.sinGen();
  carrier.setTable(sharedSine);
  modulator.setTable(sharedSine);
}

Key Methods

Function Description
setFreq(hz) Set frequency in Hz.
setPitch(midi) Set frequency from MIDI note.
setPhase(0.0-1.0) Set phase position.
setSpread(amount) Detuning thickness (e.g. 0.01).
setPulseWidth(0.05-0.95) PWM duty cycle.
setCMRatio(ratio) C:M ratio for FM anti-aliasing depth cap.
disableAntiAlias() Disable FM depth cap (for feedback FM, intentional aliasing).
next() Get next sample (fast).
next2() Get next sample (interpolated, higher quality).
nextUnlocked() Avoid atomic phase advance when one partition exclusively owns the oscillator.
setSandH(bool) Enable sample-and-hold playback of a noise table.
noiseGen(grainSize) Generate noise whose table value is held for grainSize table entries.

Modulation

Function Description
phMod(modulator, index) Phase modulation (FM). Pass int16_t value.
phMod(modOsc, index) FM with atomically paired carrier+modulator advance (dual-core safe).
phModInt(modulator, scaledIndex) Integer FM (faster).
phModInt(modOsc, scaledIndex) Integer FM, dual-core safe paired advance.
ringMod(audioIn) Ring modulation.
feedback(index) Self-feedback FM.
nextMorph(table, amount) Crossfade between current and target table.
nextWTrans(table, windowSize, dual, invert) Window transform.

Dual-core FM rule: Always use the Osc& overloads (phMod(osc, ...)) instead of calling .next() separately to avoid race conditions. The *Unlocked methods are only for oscillator/filter state permanently owned by one audio partition.


Env.h — Envelope Generator

Attack-Hold-Decay-Sustain-Release envelope with millisecond timing.

Env env;
env.setAttack(10);
env.setHold(0);
env.setDecay(100);
env.setSustain(0.5);
env.setRelease(200);
env.setMaxLevel(1.0); // 0.0-1.0 peak gain
Function Description
start() Trigger the envelope.
startRelease() Begin release phase (note off).
getValue() Get current level (call per sample in audioUpdate).
next() Same as getValue().
setAttack(ms) Attack time in ms.
setHold(ms) Hold time in ms.
setDecay(ms) Decay time in ms.
setSustain(0.0-1.0) Sustain level.
setRelease(ms) Release time in ms.
setMaxLevel(0.0-1.0) Peak level (gain control).
setAttackCurve(0.0-1.0) 0.0 = linear, 0.5 = gentle (default), 1.0 = quadratic.
setResetOnStart(bool) Each trigger begins from zero.
setResetTransition(ms) Glide to zero before attack when reset enabled.
setDecayRepeats(n) Repeat decay phase (claps, guiro).
retriggerRelease() Restart release from the current value using the latest timing parameters.
getAttack() / getRelease() Return configured millisecond durations.
getStartTime() Return the audio-frame index where the pending start was first evaluated.

Pattern: Trigger in loop(), read getValue() per sample in audioUpdate(). start() publishes a pending start; the first audio-rate evaluation anchors it so short attacks begin at their exact starting level.


SVF.h — State Variable Filter

Multi-mode resonant filter with simultaneous LPF, HPF, BPF, and Notch.

Safe frequency range: 40 Hz to ~21% of sample rate (~9200 Hz at 44.1 kHz).

SVF svf;
svf.setFreq(1000);       // Hz
svf.setRes(0.8);         // 0.3-0.84

int16_t lp = svf.nextLPF(input);
int16_t hp = svf.nextHPF(input);
int16_t bp = svf.nextBPF(input);
int16_t notch = svf.nextNotch(input);
int16_t mix = svf.nextFiltMix(input, 0.5); // LPF-BPF-HPF crossfade
svf.reset(); // Clear state

SVF2.h — Higher-Quality SVF

64-bit math with gain compensation at high resonance. Higher CPU than SVF.

SVF2 svf2;
svf2.setFreq(1000);
svf2.setRes(0.8); // 0.01-1.0, gain-compensated
int16_t lp = svf2.nextLPF(input);
svf2.reset();

Bob.h — Moog Ladder Filter

Floating point 4-pole lowpass with tanh saturation. Warmer character than SVF.

Bob bob;
bob.setFreq(1000);
bob.setRes(0.8); // 0.0-1.0
int16_t out = bob.next(input);

EMA.h — Simple IIR Filter

Minimal CPU single-pole low/high pass.

EMA ema;
ema.setFreq(5000);
int16_t lp = ema.nextLPF(input);
int16_t hp = ema.nextHPF(input);

ema.setCutoff(0.5f);            // Beginner-friendly normalized cutoff
float cutoff = ema.getCutoff(); // 0.0-1.0
ema.reset();                    // Clear filter history

setCoefficient() accepts a precomputed 10-1024 coefficient. Use EMA::coefficientForCutoff() during setup when building lookup tables; ordinary control code should prefer setFreq() or setCutoff(). Coefficient changes are safe between control and audio contexts.


Del.h — Delay Line

Audio delay with feedback and filtering.

Del del;
del.setMaxDelayTime(500); // ms, allocates buffer
del.setTime(200);         // ms
del.setLevel(0.8);        // 0.0-1.0
del.setFeedback(true);
del.setFeedbackLevel(0.5);
del.setFiltered(2);       // 0-4, higher = duller

int16_t out = del.next(input);
int16_t read = del.read();     // Read without writing
del.write(sample);             // Write without reading

BBD.h — Bucket Brigade Delay

Analog BBD / tape delay emulation with pitch-shift artefact when delay time changes.

BBD bbd;
bbd.setTime(200);        // ms
bbd.setScanRate(1.0);    // Clock rate multiplier
bbd.setLevel(0.8);
bbd.setDelayMix(0.5);    // 0=dry, 1=wet
bbd.setFeedback(true);
bbd.setFeedbackLevel(0.5);
bbd.setFiltered(2);      // 0-4

int16_t out = bbd.next(input);

FX.h — Effects Processor

Distortion

FX fx;
int16_t out = fx.softClip(sample, 3.0);      // Tube-style
int16_t out = fx.softClipAtan(sample, 3.0);  // Warm atan
int16_t out = fx.softClipCubic(sample, 3.0); // Bright
int16_t out = fx.softClipTanh(sample, 3.0);  // Balanced
int16_t out = fx.waveFold(sample, 2.0);      // Wave folding
int16_t out = fx.overdrive(sample, 2.0);     // Filtered overdrive
int16_t out = fx.bitCrush(sample, 8);        // Bit depth 1-16

Transparent Limiting

int16_t out = fx.softLimit(sample);           // Linear below 85% full scale
int16_t out = fx.softLimit(sample, 0.9f);     // Custom threshold

Reverb

fx.initReverbSafe();          // Call in setup()
fx.setReverbLength(0.8);      // 0.0-1.0
fx.setReverbMix(0.3);         // Wet 0.0-1.0
fx.setDampening(0.3);         // HF absorption
fx.setReverbSize(4.0);        // Memory multiplier

int16_t mono = fx.reverb(input);
fx.reverbStereo(inL, inR, outL, outR);
fx.reverbStereo2(inL, inR, outL, outR);     // With allpass preprocessing, half-rate CPU
fx.reverbStereoInterp(inL, inR, outL, outR); // Half-rate CPU saver
fx.resetReverbInterp();                       // Explicitly reset interpolation history

For dual-core: use setAudioPostProcessCallback() to run reverb on the combined mix.

Chorus

fx.setChorusMix(0.5);           // Dry/wet 0.0-1.0
fx.setChorusDepth(0.5);         // Internal balance
fx.setChorusWidth(0.3);         // LFO pitch depth
fx.setChorusRate(0.5);          // LFO Hz
fx.setChorusFeedback(0.3);      // 0.0-1.0
fx.setChorusDelayTime(30);      // Base delay ms (20-40)
fx.setChorusStereoDetune(0.6);  // L/R rate difference
fx.setChorusSpread(0.5);        // Stereo width 0.0-1.0

int16_t mono = fx.chorus(input);
fx.chorusStereo(inL, inR, outL, outR);

Stereo chorus uses independent left/right triangle LFOs with slightly different rates. setChorusStereoDetune() controls their rate separation, producing a slowly evolving stereo relationship instead of mirrored modulation.

Compression

fx.setCompression(threshold, ratio, attack, release);            // Unity makeup
fx.setCompression(threshold, ratio, attack, release, makeup);    // Explicit makeup

int32_t mono = fx.compression(sample);
fx.compressionStereo(inL, inR, outL, outR);

Wave Shaping

fx.setShapeTableSoftClip(5.0);
int16_t out = fx.waveShaper(input, 0.8);

Smoothing

fx.smooth(sample, 0.1);
fx.smoothStereo(inL, inR, outL, outR, 0.1);

Gain.h — Level Control

Cross-core-safe fixed-point gain stage.

Gain gain;
gain.setLevel(768);       // 0-1024
gain.setLevel(0.75f);     // 0.0-1.0
int32_t out = gain.next(input);
int level = gain.getLevel();
float normal = gain.getLevelNormal();

Samp.h — Sample Playback

Mono/stereo sample playback with variable speed, looping, and granular features.

Samp samp;
samp.setTable(buffer, frameCount, sampleRate, numChannels);
samp.setStart(0);
samp.setEnd(10000);
samp.setSpeed(1.0);
samp.setFreq(880);             // Pitch-based speed
samp.setBasePitch(69);         // Reference MIDI pitch
samp.setPitch(72);             // Playback pitch
samp.setLoopingOn();
samp.setReverse(true);
samp.setInterpolation(true);
samp.setEdgeFade(true);        // Granular click reduction
samp.setNearZeroSmooth(true);  // Reduce quantisation clicks

Samp::initSharedEnvelope(2048, 0.8f, 0); // Gaussian grain envelope
samp.setEnvPhaseOffset(0.25f);

samp.start();
int16_t mono = samp.next();
bool playing = samp.nextStereo(leftOut, rightOut);

nextStereo() returns whether playback remains active. nextLeft() and nextRight() are also available, but nextStereo() keeps stereo frames paired.

Flash-Stored Samples

#include "sample_adpcm.h" // Generated offline

Wav wav;
Samp samp;
samp.loadFromFlash(wav, SAMPLE_DATA, SAMPLE_DATA_SIZE);

Phys.h — Physical Modelling

Karplus-Strong plucked string and digital waveguide.

Phys phys;
phys.setPluckPosition(0.5);    // 0.0=read head, 1.0=write head
int16_t out = phys.pluck(noiseInput, 440.0, 0.99); // input, freq, depth

// Or with stored frequency:
phys.setPluckFreq(440.0f);
int16_t out = phys.pluck(noiseInput, 0.99);
phys.resetPluck();
phys.setPluckSilenceThreshold(4); // terminate low-level integer tails
phys.setPluckDampCutoff(0.5f);    // normalized damping cutoff

// Digital waveguide:
int16_t out = phys.waveguide(noiseInput, 440.0, 0.99);

Verb.h — Freeverb Reverb

Standalone Freeverb-style reverb (different character to FX.h reverb).

Verb verb;
verb.setHighQuality(true);  // 8 combs + 4 allpass. Call before init().
verb.setUsePSRAM(true);      // Call before init(); ignored if unavailable
verb.initVerbSafe();         // Allocate buffers. Call in setup().
verb.setReverbLength(0.8);   // 0.0-1.0 room size / decay
verb.setDampening(0.3);      // 0.0-1.0 HF absorption
verb.setReverbMix(0.5);      // 0=dry, 1=wet
verb.setWidth(1.0);          // 0=mono, 1=full stereo

int16_t mono = verb.reverb(input);
verb.reverbStereo(inL, inR, outL, outR);

All.h — Allpass Filter

Schroeder allpass for reverb diffusion and phase effects.

All all;
all.setDelayTime(50);       // ms
all.setFeedbackLevel(0.7);  // 0.0-1.0
int16_t out = all.next(input);

Comb.h — Comb Filter

Comb comb;
comb.setDelayTime(10);          // ms
comb.setInputLevel(1.0);
comb.setFeedforwardLevel(0.7);
comb.setFeedbackLevel(0.5);
int16_t out = comb.next(input);

Arp.h — Arpeggiator

int notes[] = {60, 64, 67, 72};
Arp arp(notes, 4, 2, ARP_UP_DOWN); // values, count, octaves, direction

arp.start();
int pitch = arp.next();
double ms = arp.calcStepDelta(120, 4); // BPM, subdivision

Directions: ARP_ORDER, ARP_UP, ARP_UP_DOWN, ARP_DOWN, ARP_RANDOM, ARP_RANDOM2.

Seq.h — Step Sequencer

int pattern[] = {60, 0, 64, 0, 67, 0, 72, 0};
Seq seq(pattern, 8, 4); // values, size, stepDiv

seq.setRandom(true);
seq.euclideanGen(100, 5, 0);     // value, hits, rotate
seq.randWalkGen(60, 3, 48, 72); // start, maxDev, min, max

int val = seq.next();
double ms = Seq::calcStepDelta(120, 4, 2); // BPM, slice, div

MIDI16.h — MIDI I/O

Lightweight UART MIDI for ESP32, ESP8266, and Pico-family boards. MIDI16 does not currently configure a Teensy UART or USB MIDI endpoint; use Teensyduino's MIDI or USB MIDI facilities on Teensy.

MIDI16 midi(rxPin, txPin); // ESP32 defaults: rx=37, tx=38

midi.sendNoteOn(channel, pitch, velocity);
midi.sendNoteOff(channel, pitch, velocity);
midi.sendControlChange(channel, cc, value);
midi.sendClock();

uint8_t status;
while ((status = midi.read()) != 0) {
  int ch = midi.getChannel();
  int d1 = midi.getData1();
  int d2 = midi.getData2();
}

int16_t bpm = midi.clockToBpm();

ESP32 Clock Task (optional, recommended)

midi.beginClockTask();       // Start high-priority clock task
midi.setClockSendBpm(120.0); // Auto-send clock pulses
midi.stopClockSend();

The background clock task and queued-TX diagnostic counters are ESP32-only.


Mic.h — Audio Input

Mic mic;
int16_t l = mic.nextLeft();
int16_t r = mic.nextRight();
mic.nextStereo(l, r);

Call audioInputStart() before reading on Pico and Teensy; it may be called before audioStart(), as in AudioPassthrough. On ESP32 the RX channel starts with audioStart() whenever DIN is not -1.

Teensy uses PJRC AudioInputI2S with DIN 8, LRCLK 20, BCLK 21, and MCLK 23 when required. Input is captured into fixed storage with one 128-frame AudioStream block (~2.9 ms) of latency, so unread input cannot exhaust AudioMemory. Generic microphones must be clocked I2S slaves, not PDM devices. The Teensy Audio Board additionally needs an AudioControlSGTL5000 object configured in the sketch.

// Optional Teensy Audio Board codec control (provided by Teensyduino):
AudioControlSGTL5000 codec;

// After audioStart():
codec.enable();
codec.inputSelect(AUDIO_INPUT_MIC); // or AUDIO_INPUT_LINEIN
codec.micGain(30);
codec.volume(0.5f);

Sync.h — Audio Sync Clock

Sends/receives analogue sync pulses (Korg Volca, Pocket Operators).

Sync sync(inPin, outPin);
sync.setPPQN(2);
sync.setOutBpm(120.0f);

// In loop():
if (sync.pulseOnTime(millis())) sync.startPulse();
if (sync.pulseOffTime(millis())) sync.endPulse();
bool beat = sync.receivePulse(millis());

Requires a ~2:1 voltage divider on output (3.3V -> ~1.4V). The default constructor uses input pin 45 and output pin 46.


TLV.h — TLV320AIC3104 Codec

The TLV driver is implemented only on ESP32. Other platforms provide a source-compatible stub whose begin() returns false.

TLV tlv(sdaPin, sclPin);
tlv.begin(SAMPLE_RATE); // Call after audioStart()

Move I2S DIN from GPIO21 when using this codec (I2C conflict).


Wav.h — WAV and ADPCM File Loading

Loads PCM WAV files from SdFat storage, supports streaming reads, and can load M16 ADPCM data from flash. Wav owns or borrows the decoded sample buffer; pass its metadata to Samp or use Samp::loadFromFlash().

SdFs sd;
Wav wav;

wav.initSD(sd, csPin, sckPin, misoPin, mosiPin);
wav.setMaxAllocation(2 * 1024 * 1024); // optional byte limit
if (wav.load("/sample.wav")) {
  Samp sample;
  sample.setTable(wav.getBuffer(), wav.getFrameCount(),
                  wav.getSampleRate(), wav.getChannels());
}

File navigation includes countWavFiles(), loadFirst(), loadNext(), loadPrev(), and loadNumber(). Streaming uses openForStreaming(), readFramesFast() / readFramesWrapFast(), then closeForStreaming(). loadFromFlash() reads exported ADPCM data; compress() and exportToHeader() provide offline preparation helpers.


Default I2S Pins

Platform Output BCLK Output LRCLK DOUT DIN Input clocks
ESP8266 GPIO15 GPIO2 GPIO3 Not implemented by M16 Hardware input uses GPIO13 BCLK, GPIO14 LRCLK, GPIO12 DIN
ESP32 GPIO16 GPIO17 GPIO18 GPIO21 Shares output BCLK/LRCLK
Pico / Pico 2 GPIO16 GPIO17 GPIO18 GPIO19 Separate BCLK GPIO20, LRCLK GPIO21 by default
Teensy 4.x Pin 21 Pin 20 Pin 7 Pin 8 Shares BCLK/LRCLK; MCLK pin 23

ESP32 and Pico output pins are configurable with seti2sPins(). Pico's current separate input clock remains output BCLK + 4. ESP8266 and Teensy pin routing is fixed.


Tips

  • Call fx.initReverbSafe() and other heavy allocations in setup(), never in audioUpdate().
  • Avoid Serial.print(), malloc(), and rand() in audioUpdate().
  • Use PSRAM for large buffers: isPSRAMAvailable() checks availability.
  • SVF is stable up to ~21% of sample rate. Use SVF2 or Bob for high resonance.
  • For dual-core polyphony, use setAudioPostProcessCallback() for master reverb/chorus.
  • Original Pico partitioned mode requires audioLoop() in loop(); dedicated mode does not, and Pico 2's automatic worker makes it a no-op.

Clone this wiki locally