OXRecorder::OXAudioManager
INMP441 microphone capture — start / stop / pause and continuous PCM delivery
Overview
OXAudioManager drives an INMP441 I2S MEMS
microphone: it installs the I2S peripheral, controls the recording
lifecycle (begin / pause / resume /
stop), and continuously delivers recorded
16-bit PCM for further processing — writing to disk via
OXFileManager, streaming, or
encrypting with OXSecurity.
There are two ways to receive audio, usable together or separately:
-
Push — register a callback with
onAudio(); it fires with each freshly captured chunk when you pumppoll(). -
Pull — call
read()to drain PCM thatpoll()has buffered, on your own schedule.
The default pin map and audio format match the validated
INMP441-ESP32-S3 reference exactly, and every parameter is
overridable through AudioConfig. The
raw 32-bit I2S words are also exposed for advanced DSP.
Declared in include/OXAudioManager.h; portable core in
src/OXAudioManager.cpp; the ESP-IDF I2S backend in
src/audio/Esp32I2SDevice.cpp. Diagnostics go through
OXLog.
Wiring (INMP441 → ESP32-S3 Super Mini)
Default pins, matching the reference hardware:
| INMP441 pin | ESP32-S3 Super Mini | Notes |
|---|---|---|
| VDD | 3V3 | 3.3 V only. |
| GND | GND | |
| SD (data out) | GPIO7 | dataPin |
| SCK (bit clock) | GPIO9 | bclkPin |
| WS (word select) | GPIO8 | wsPin |
| L/R | GND | Selects the mic's channel — see note below. |
With L/R tied to GND, the manager must select the right I2S slot
(leftChannel = false, the default) to actually capture the mic.
The selector is inverted relative to the WS phase; this is intentional and
explained on the internals page.
AudioConfig
All fields default to the validated reference values; override any of them before calling begin().
| Field | Default | Meaning |
|---|---|---|
bclkPin | 9 | I2S bit clock (INMP441 SCK). |
wsPin | 8 | I2S word select (INMP441 WS). |
dataPin | 7 | I2S data in (INMP441 SD). |
sampleRateHz | 16000 | Sample rate, mono. |
leftChannel | false | false = ONLY_RIGHT (correct for L/R=GND). |
sampleShift | 16 | Right-shift to extract 16-bit PCM from the left-justified word. |
gainShift | 1 | Extra digital gain (left-shift); 1 ≈ +6 dB. |
dmaBufCount | 8 | I2S DMA buffer count. |
dmaBufLen | 512 | Samples per DMA buffer (keep the byte size < 4092). |
AudioState enum class
| Value | Meaning |
|---|---|
Stopped | Driver not installed. poll() is a no-op. |
Running | Capturing; poll() delivers samples. |
Paused | Driver still installed, delivery suppressed; poll() drains and discards. |
API reference
Lifecycle
bool begin(const AudioConfig &cfg = AudioConfig{})
Installs the I2S driver with cfg and enters Running. Returns false if no device is available or the driver fails to install.
bool pause()
Suppresses delivery but keeps the driver installed; poll() keeps the DMA drained so audio does not burst on resume. Returns false if not currently running.
bool resume()
Resumes delivery (after zeroing the DMA buffer). Returns false if not currently paused.
bool stop()
Uninstalls the driver, releases the pins, and clears the pull buffer. Returns false if already stopped.
AudioState state() const · bool isRunning() const
Query the current lifecycle state.
Receiving audio
void onAudio(AudioCallback cb, void *ctx = nullptr)
Registers a push callback
void(const int16_t *pcm, size_t sampleCount, void *ctx),
invoked by poll() with each chunk. The pcm
pointer is valid only during the call. Pass nullptr to clear.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
cb | AudioCallback | — | Function called per chunk. Its args: pcm (samples, valid only during the call), sampleCount (number of samples), ctx (your pointer). Pass nullptr to unregister. |
ctx | void * | nullptr | Opaque pointer forwarded to every callback (e.g. a file handle or accumulator). |
size_t poll()
The pump: reads one DMA chunk, converts it to PCM, dispatches to the
callback, and buffers it for read(). Call regularly from
loop(). Returns the number of samples produced (0 when
paused, stopped, or none ready).
Takes no parameters. Returns: PCM sample count produced this call.
size_t read(int16_t *out, size_t maxSamples)
Drains up to maxSamples buffered PCM samples into
out and returns the count copied. Complements the callback —
use either, both, or neither.
| Parameter | Type | Meaning |
|---|---|---|
out | int16_t * | Destination buffer for signed 16-bit mono PCM samples. |
maxSamples | size_t | Capacity of out in samples (not bytes). At most this many are copied. |
Returns: number of samples copied (0 if nothing is buffered).
const int32_t *lastRaw() const · size_t lastRawCount() const
The raw 32-bit I2S words from the most recent poll(), for advanced DSP. Valid until the next poll().
int16_t convertSample(int32_t raw) const
Converts one raw left-justified word to 16-bit PCM using the configured shift and gain (with clamping). Exposed for testing and reuse.
Test seam
void setDevice(II2SDevice *device) · static void setDefaultDeviceFactory(DeviceFactory)
setDevice() injects an I2S device (not owned). On-device the
ESP32 backend registers a default-device factory at startup, so you never
call these in production; the host unit tests use them to substitute a
fake device.
Examples
Try it
These examples need the INMP441 wired as in the table above. Flash with
pio run -e esp32-s3-super-mini -t upload && pio device monitor.
The state machine and sample conversion are covered on the host by
pio test -e native (via a fake I2S device), so logic changes
can be checked without hardware.
1. Stream the microphone to an SD file (push callback)
The most common pattern: register a callback that writes each PCM chunk to
a file via OXFileManager, then pump poll() from
loop().
#include <Arduino.h>
#include "OXAudioManager.h"
#include "OXFileManager.h"
#include "OXLog.h"
using namespace OXRecorder;
OXAudioManager audio;
OXFileManager fm;
OXFileStream recFile;
// Called by poll() with each chunk of captured PCM.
void onAudio(const int16_t *pcm, size_t n, void *ctx) {
if (recFile.isOpen()) {
recFile.write(reinterpret_cast<const uint8_t *>(pcm), n * sizeof(int16_t));
}
}
void setup() {
OXLog::begin(115200);
fm.beginExternal(); // mount SD card
fm.mkdir(Storage::External, "/rec");
recFile = fm.openWrite(Storage::External, "/rec/take01.pcm");
audio.onAudio(onAudio);
if (!audio.begin()) { // default pins/format
OXLog::error("mic start failed");
}
}
void loop() {
audio.poll(); // pump capture -> onAudio()
}
2. Pull samples with read()
If you would rather own the cadence, skip the callback and drain buffered
PCM yourself. Still call poll() to pump the hardware.
OXAudioManager audio;
void setup() {
OXLog::begin(115200);
audio.begin();
}
void loop() {
audio.poll(); // capture into the internal buffer
int16_t buf[256];
size_t n = audio.read(buf, 256); // pull what's available
if (n > 0) {
// ... process n samples in buf (FFT, level meter, send over network) ...
}
}
3. Pause & resume
pause() stops delivery instantly without tearing down the
driver, so resume() has no setup latency. Useful for
push-to-talk or muting during playback.
// Mute while a button is held, capture otherwise.
if (buttonPressed && audio.isRunning()) {
audio.pause();
} else if (!buttonPressed && audio.state() == AudioState::Paused) {
audio.resume();
}
audio.poll(); // while paused, poll() drains DMA but delivers nothing
4. Stop and free the peripheral
audio.stop(); // uninstalls I2S, releases GPIO9/8/7
// The pins are now free for other use; call begin() again to restart.
5. Override the pins / format
Construct an AudioConfig, change what you need, and pass it to
begin(). Everything you do not set keeps the reference default.
AudioConfig cfg;
cfg.bclkPin = 4; // different wiring
cfg.wsPin = 5;
cfg.dataPin = 6;
cfg.sampleRateHz = 8000; // 8 kHz instead of 16 kHz
cfg.gainShift = 2; // ~+12 dB
audio.begin(cfg);
Sample output
[31][I] OXAudioManager: started (16000 Hz, right channel)
[42][I] OXAudioManager: paused
[55][I] OXAudioManager: resumed
[80][I] OXAudioManager: stopped
Complete recording sketch
A full, working src/main.cpp that records a fixed-length clip
to the SD card and finalizes it safely. It combines everything above:
mount the card, open a streaming file, register the callback, pump
poll() with no blocking delay, flush periodically
so a power cut can't lose buffered data, and close cleanly
so the file's size is written to the directory.
Why flush + close matter
OXFileStream buffers writes; the on-disk file size is only
finalized on flush()/close(). If you just pull
the power, the file shows 0 bytes even though data was
captured. Always wait for the "safe to power off" log before removing
power or the card.
#include <Arduino.h>
#include "OXAudioManager.h"
#include "OXFileManager.h"
#include "OXLog.h"
using namespace OXRecorder;
OXAudioManager audio;
OXFileManager fm;
OXFileStream recFile;
// How long to record before finalizing the file, in milliseconds.
static const uint32_t kRecordMs = 10000; // 10 seconds
// Flush (commit buffered bytes + update the directory size) this often.
// Without periodic sync, a power loss loses everything since the file opened.
static const uint32_t kFlushIntervalMs = 1000;
static uint32_t g_recStartMs = 0;
static uint32_t g_lastFlushMs = 0;
static uint32_t g_bytesWritten = 0;
static bool g_recording = false;
// Called by audio.poll() with each chunk of captured PCM.
void onAudio(const int16_t *pcm, size_t n, void *ctx) {
if (!recFile.isOpen()) {
return;
}
size_t bytes = n * sizeof(int16_t);
size_t w = recFile.write(reinterpret_cast<const uint8_t *>(pcm), bytes);
g_bytesWritten += w;
if (w != bytes) {
OXLog::warn("short write (%u/%u) — SD full or too slow?", (unsigned)w,
(unsigned)bytes);
}
}
void setup() {
OXLog::begin(115200);
delay(1000); // give the USB CDC serial a moment to come up
OXLog::info("OXRecorder audio capture starting");
// SD card on the default ESP32-S3 pins (CS=10, SCK=11, MISO=13, MOSI=12).
if (!fm.beginExternal()) {
OXLog::error("SD card mount failed");
return;
}
fm.mkdir(Storage::External, "/rec");
recFile = fm.openWrite(Storage::External, "/rec/take01.pcm");
if (!recFile.isOpen()) {
OXLog::error("could not open /rec/take01.pcm for writing");
return;
}
audio.onAudio(onAudio);
if (!audio.begin()) { // default format: 16 kHz mono, BCLK=1 WS=2 DATA=21
OXLog::error("mic start failed");
return;
}
g_recording = true;
g_recStartMs = millis();
g_lastFlushMs = g_recStartMs;
OXLog::info("recording %lu ms to /rec/take01.pcm ...",
(unsigned long)kRecordMs);
}
void loop() {
if (!g_recording) {
return; // idle; safe to power off after the "complete" log below
}
// Pump the mic as fast as possible — drains the I2S DMA and delivers PCM to
// onAudio(). Do NOT put blocking delay() here or the DMA overflows.
audio.poll();
uint32_t now = millis();
// Periodically commit buffered data so a power cut doesn't lose it.
if (now - g_lastFlushMs >= kFlushIntervalMs) {
recFile.flush();
g_lastFlushMs = now;
OXLog::info("... %u bytes written", (unsigned)g_bytesWritten);
}
// Finalize when the recording window elapses.
if (now - g_recStartMs >= kRecordMs) {
audio.stop(); // stop capture
recFile.flush(); // commit the last bytes
recFile.close(); // write the final directory entry (file size)
g_recording = false;
OXLog::info("recording complete: %u bytes in /rec/take01.pcm",
(unsigned)g_bytesWritten);
OXLog::info("SD free: %u bytes — safe to power off now",
(unsigned)fm.freeBytes(Storage::External));
}
}
Recorded PCM format
OXAudioManager delivers — and the sketch above writes —
raw, headerless PCM with these properties (the
AudioConfig defaults):
| Property | Value |
|---|---|
| Sample rate | 16000 Hz (sampleRateHz) |
| Channels | 1 — mono |
| Bit depth | 16-bit signed (int16_t) |
| Byte order | little-endian |
| Container | none — raw samples (.pcm) |
That is 16000 × 2 = 32000 bytes per second, so a 10-second
clip is about 320 KB. There is no header in the file: the
format is implied, not stored. If you change sampleRateHz in
AudioConfig, update the conversion command below to match.
Converting the .pcm to a .wav
Players generally can't open a headerless .pcm directly, so
either tell the player the format explicitly or wrap the samples in a WAV
header. The easiest path is ffmpeg — copy
take01.pcm off the SD card to your computer and run:
ffmpeg -f s16le -ar 16000 -ac 1 -i take01.pcm take01.wav
| Flag | Meaning |
|---|---|
-f s16le | Input format: signed 16-bit little-endian PCM (matches the 16-bit int16_t samples). |
-ar 16000 | Audio sample rate: 16000 Hz (must match sampleRateHz). |
-ac 1 | Audio channels: 1 (mono). |
-i take01.pcm | Input file (the raw recording). |
take01.wav | Output file; ffmpeg writes a proper WAV header so any player opens it. |
To listen without converting, point a player at the raw file with the same parameters:
ffplay -f s16le -ar 16000 -ch_layout mono take01.pcm
# or with SoX:
play -t raw -e signed -b 16 -r 16000 -c 1 take01.pcm
Prefer a ready-to-play file on the device? Have the firmware write a 44-byte
WAV header before the samples and patch in the sizes at
close() — then no host-side conversion is needed.
Notes
-
Pump regularly. Capture is cooperative:
poll()must be called often enough to keep up with the DMA, or the driver's buffers fill and samples are lost. Avoid long blocking work inloop(). -
Pull buffer is bounded. If you use
read()and drain too slowly, the internal ring (2048 samples) overflows and drops the oldest samples, with anOXLogwarning. Drain promptly or rely on the callback. -
Mono, 16-bit. Delivered PCM is single-channel signed
16-bit at
sampleRateHz— write it straight into a WAV body or stream it as-is.