How OXAudioManager Works

A walkthrough of the design and internals

What this page is

The API reference tells you what each method does. This page explains how the class is built and why — the validated I2S configuration it preserves, the capture data flow, the pause/stop lifecycle, and how it stays testable without an ESP32. Read it before changing the I2S setup, the sample conversion, or the buffering.

Wiring diagram

The INMP441 is an I2S MEMS microphone. Wire it to the ESP32-S3 Super Mini exactly as below — these are the validated default pins (AudioConfig{}). L/R must go to GND: that selects the left time-slot, which the driver captures as ONLY_RIGHT (the selector is inverted relative to WS phase).

INMP441 I2S microphone SCK WS SD VDD GND L/R → GND ESP32-S3 DevKitM-1 GPIO9 GPIO8 GPIO7 3V3 GND
clock (BCLK/WS) data power ground

INMP441 → ESP32-S3 Super Mini (default AudioConfig pins)

INMP441ESP32-S3 Super MiniSignal
SCKGPIO9I2S bit clock (bclkPin)
WSGPIO8I2S word select (wsPin)
SDGPIO7I2S serial data out (dataPin)
VDD3V33.3 V power
GNDGNDGround
L/RGNDChannel select → left slot (captured as ONLY_RIGHT)

The design: an injected I2S device

Like the rest of the codebase — OXLog's injectable sink, OXFileManager's backends, OXSecurity's cipher — the hardware dependency sits behind a small interface so the interesting logic is host-testable. The ESP-IDF I2S calls live in one place; everything else is portable.

OXAudioManager  (src/OXAudioManager.cpp — portable: state machine, convert,
   │             callback dispatch, ring buffer, OXLog diagnostics)
   │  one injected I2S device
   ▼
II2SDevice  (pure virtual: install / uninstall / read / zeroDmaBuffer)
   ├─ Esp32I2SDevice  src/audio/Esp32I2SDevice.cpp  → driver/i2s.h (device only)
   └─ FakeI2SDevice   test/test_oxaudiomanager/...  → scripted samples (host)

II2SDevice is deliberately tiny — install the peripheral, read raw 32-bit words, zero the DMA, uninstall. The manager never includes <driver/i2s.h>; it only orchestrates. That is what lets the conversion math, the state machine, and the buffering run in a desktop unit test against a fake that returns canned samples.

Preserving the validated I2S configuration

The whole point of wrapping the reference project is to keep its known-good microphone setup. Esp32I2SDevice::install() reproduces it field for field:

i2sConfig.mode = I2S_MODE_MASTER | I2S_MODE_RX;
i2sConfig.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT;   // mic word is 32-bit
i2sConfig.channel_format = cfg.leftChannel ? ONLY_LEFT : ONLY_RIGHT;
i2sConfig.communication_format = I2S_COMM_FORMAT_STAND_I2S;
i2sConfig.dma_buf_count = 8;
i2sConfig.dma_buf_len = 512;
i2sConfig.mclk_multiple = I2S_MCLK_MULTIPLE_256;
i2sConfig.bits_per_chan = I2S_BITS_PER_CHAN_32BIT;
// ... then i2s_set_pin(), i2s_set_clk(16000, 32BIT, MONO), i2s_zero_dma_buffer()

Three of these are non-obvious and were hard-won in the reference, so they are worth calling out:

Sample conversion: 32-bit word → 16-bit PCM

Each raw word becomes one PCM sample through the reference's exact pipeline, in the portable core (so it is unit-tested):

int16_t OXAudioManager::convertSample(int32_t raw) const {
  int32_t sample = raw >> config_.sampleShift;        // 16: extract top 16 bits
  sample *= (int32_t)(1U << config_.gainShift);     // 1: ~+6 dB digital gain
  return clampPcm16(sample);                          // saturate to int16 range
}

sampleShift = 16 is the key value: it keeps the high 16 bits of the left-justified word, giving correct 16-bit PCM. A smaller shift leaves a 24-bit value that saturates to full scale after clamping — which is exactly the "loud garbage" failure mode the reference documented. gainShift then applies optional digital gain, and clampPcm16() prevents wrap-around on loud peaks. Two unit tests pin this: a known word converts to the expected value, and extreme words clamp to INT16_MIN/MAX.

Capture data flow: poll(), callback, and the ring buffer

Capture is cooperative — there is no RTOS task or ISR hidden in the class. You pump poll() from loop(), mirroring the reference's synchronous i2s_read loop. Each poll():

  1. asks the device to read() one chunk of raw 32-bit words (up to 512);
  2. converts each word to PCM into a scratch buffer;
  3. invokes the registered callback (push), if any;
  4. copies the PCM into an internal ring buffer (pull);
  5. returns the sample count.

Offering both push and pull from the same pump means a caller can stream via the callback, drain via read(), or both, without the class committing to one model. The ring buffer is a simple single-producer/single-consumer queue (poll() writes, read() drains):

// Overflow policy: if the reader can't keep up, drop the OLDEST sample so the
// freshest audio always survives (better for live monitoring than the reverse).
if (ringCount_ == kRingSamples) {
  ringTail_ = (ringTail_ + 1) % kRingSamples;
  --ringCount_;
}
ring_[ringHead_] = pcm[i];
ringHead_ = (ringHead_ + 1) % kRingSamples;
++ringCount_;

If you only use the callback, the ring is harmless overhead; if you only use read(), it gives you slack between polls. Drain promptly either way — the documented overflow drops the oldest samples.

Lifecycle: why pause keeps the driver

The state machine has three states — Stopped → Running ⇄ Paused — and the key design choice is that pause does not uninstall the driver:

Every entry point guards on state, so poll() before begin(), a double stop(), or pause() when not running are all safe no-ops — each covered by a test.

Device vs. host build

The I2S backend lives under src/audio/, which the native test environment excludes:

[env:native]
build_src_filter = +<*> -<main.cpp> -<backends/> -<crypto/> -<audio/>

To keep begin() in the portable core without linking the I2S code on the host, the default device is supplied through a factory hook rather than a hard reference. On-device, OXAudioManagerDevice.cpp registers a factory at static-init time; on the host no factory exists, so a test must inject a FakeI2SDevice first (and a test confirms begin() fails cleanly when neither is present):

// Device TU, runs before setup():
OXAudioManager::setDefaultDeviceFactory(&makeDefaultDevice);

// Core begin(): injected device wins; otherwise ask the factory.
if (device_ == nullptr && defaultFactory_ != nullptr) device_ = defaultFactory_();

The FakeI2SDevice returns scripted samples and records install/uninstall/read/zero calls, so host tests verify the full lifecycle, the conversion math, callback dispatch, the pull buffer, pause/resume, and that an overridden AudioConfig reaches the device — none of which needs real silicon.

Where it fits

OXAudioManager is one stage of a capture pipeline. A typical recorder wires it to its siblings: the onAudio callback hands each PCM chunk to OXFileManager's streaming write (to flash or SD), optionally encrypting first with OXSecurity, and logs status through OXLog. Keeping audio capture isolated behind this class means none of that wiring leaks into the I2S code, and each piece is testable on its own.

File map

FileRole
include/OXAudioManager.hPublic API, AudioConfig, AudioState, II2SDevice interface.
src/OXAudioManager.cppPortable core: state machine, begin(), convert, poll/read, ring buffer. No I2S headers.
include/audio/Esp32I2SDevice.h · src/audio/Esp32I2SDevice.cppESP-IDF I2S backend with the validated config (device only).
src/audio/OXAudioManagerDevice.cppRegisters the default-device factory at startup (device only).
test/test_oxaudiomanager/FakeI2SDevice.hScripted I2S device for host tests.
test/test_oxaudiomanager/test_oxaudiomanager.cppUnity suite: lifecycle, conversion, callback/read, pause/resume, config passthrough, safety.