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:

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 pinESP32-S3 Super MiniNotes
VDD3V33.3 V only.
GNDGND
SD (data out)GPIO7dataPin
SCK (bit clock)GPIO9bclkPin
WS (word select)GPIO8wsPin
L/RGNDSelects 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().

FieldDefaultMeaning
bclkPin9I2S bit clock (INMP441 SCK).
wsPin8I2S word select (INMP441 WS).
dataPin7I2S data in (INMP441 SD).
sampleRateHz16000Sample rate, mono.
leftChannelfalsefalse = ONLY_RIGHT (correct for L/R=GND).
sampleShift16Right-shift to extract 16-bit PCM from the left-justified word.
gainShift1Extra digital gain (left-shift); 1 ≈ +6 dB.
dmaBufCount8I2S DMA buffer count.
dmaBufLen512Samples per DMA buffer (keep the byte size < 4092).

AudioState enum class

ValueMeaning
StoppedDriver not installed. poll() is a no-op.
RunningCapturing; poll() delivers samples.
PausedDriver 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.

ParameterTypeDefaultMeaning
cbAudioCallbackFunction called per chunk. Its args: pcm (samples, valid only during the call), sampleCount (number of samples), ctx (your pointer). Pass nullptr to unregister.
ctxvoid *nullptrOpaque 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.

ParameterTypeMeaning
outint16_t *Destination buffer for signed 16-bit mono PCM samples.
maxSamplessize_tCapacity 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):

PropertyValue
Sample rate16000 Hz (sampleRateHz)
Channels1 — mono
Bit depth16-bit signed (int16_t)
Byte orderlittle-endian
Containernone — 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
FlagMeaning
-f s16leInput format: signed 16-bit little-endian PCM (matches the 16-bit int16_t samples).
-ar 16000Audio sample rate: 16000 Hz (must match sampleRateHz).
-ac 1Audio channels: 1 (mono).
-i take01.pcmInput file (the raw recording).
take01.wavOutput 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