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 → ESP32-S3 Super Mini (default AudioConfig pins)
| INMP441 | ESP32-S3 Super Mini | Signal |
|---|---|---|
SCK | GPIO9 | I2S bit clock (bclkPin) |
WS | GPIO8 | I2S word select (wsPin) |
SD | GPIO7 | I2S serial data out (dataPin) |
VDD | 3V3 | 3.3 V power |
GND | GND | Ground |
L/R | GND | Channel 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:
-
ONLY_RIGHT for an L/R=GND mic. The INMP441 transmits on
the left slot when L/R is grounded — but the ESP32 driver's channel
selector is inverted relative to the WS phase, so you must select
ONLY_RIGHTto actually receive it. Selecting LEFT reads the idle slot and yields near-silence. HenceleftChanneldefaults tofalse. - 32-bit slots for a 24-bit mic. The INMP441 is left-justified: its 24-bit sample sits in bits 31..8 of a 32-bit word, low 8 bits zero. Reading 32-bit slots and shifting is what recovers it.
-
DMA sizing.
dma_buf_len = 512samples keeps the real DMA descriptor under the ESP-IDF 4092-byte limit; larger values fail to allocate.
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():
- asks the device to
read()one chunk of raw 32-bit words (up to 512); - converts each word to PCM into a scratch buffer;
- invokes the registered callback (push), if any;
- copies the PCM into an internal ring buffer (pull);
- 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:
-
begin()installs the driver, zeros the DMA, and goesRunning. -
pause()flips toPaused.poll()still calls the device'sread()to drain the DMA, but discards the result — so stale audio captured during the pause does not burst out on resume — and it neither converts, delivers, nor buffers. -
resume()zeros the DMA and returns toRunning. Because the driver was never torn down, resume is instant — no re-install latency. That is what makes pause suitable for push-to-talk or muting. -
stop()uninstalls the driver and releases GPIO9/8/7 (returns them to inputs), matching the reference'sshutdownI2s()so an SD-only path is quiet afterward. It also clears the ring buffer for a clean restart.
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
| File | Role |
|---|---|
include/OXAudioManager.h | Public API, AudioConfig, AudioState, II2SDevice interface. |
src/OXAudioManager.cpp | Portable core: state machine, begin(), convert, poll/read, ring buffer. No I2S headers. |
include/audio/Esp32I2SDevice.h · src/audio/Esp32I2SDevice.cpp | ESP-IDF I2S backend with the validated config (device only). |
src/audio/OXAudioManagerDevice.cpp | Registers the default-device factory at startup (device only). |
test/test_oxaudiomanager/FakeI2SDevice.h | Scripted I2S device for host tests. |
test/test_oxaudiomanager/test_oxaudiomanager.cpp | Unity suite: lifecycle, conversion, callback/read, pause/resume, config passthrough, safety. |