← All projects

Skribe: streaming microphone audio off an ESP32-C5

A firmware project to take sound from a MEMS microphone, move it through a real-time task pipeline, and stream it over Wi-Fi to a computer. Capture and the task pipeline work. Streaming is the part I'm building now.

When
July–August 2026, ongoing
Stack
C, ESP-IDF, FreeRTOS, lwIP
My role
Solo project

Why build it

ESP32-C5 development board
The ESP32-C5 dev board Skribe runs on.

I wanted a project that pushes continuous data through every layer of an embedded system: a digital peripheral, an RTOS, a network stack, and a host program on the other end. Audio is a good test because it never stops. If any stage falls behind, you hear it.

Hardware: an ESP32-C5 development board (KitC-A v1.2) and an INMP441 MEMS microphone, which outputs digital audio over I2S, so there's no analog front end to design.

The pipeline

Solid lines are working in the current firmware. Dashed lines are the next milestones. Click any block for details.

Click or tab through the blocks.

xQueueSend xQueueReceive I2S · 16 kHz INMP441MEMS mic Capture taskpriority 5 Queue4 × 120 samples Consumer taskpriority 4 Wi-Fi stationauto-reconnect UDP senderport 5000 · next Linux hostselect() → WAV · next
WorkingNext milestone

Design decisions

Two tasks, not one loop. A single loop that reads, processes and sends would stall the microphone whenever the network is slow. Splitting producer and consumer, with capture at the higher priority, keeps acquisition steady.

Blocks, not single samples. Moving 120 samples at a time keeps queue overhead and, later, per-packet network overhead low, while each block stays short enough to keep latency small.

Prove each stage before adding the next. I got the microphone producing sensible peak values before adding Wi-Fi, and I'm getting Wi-Fi stable before sending audio over it. When something breaks, I know which layer to look at.

The code

The heart of the pipeline: two tasks and the queue between them.

void audio_capture_task(void *parameter) {
    audio_block_t block;
    while (1) {
        // Block until the I2S DMA delivers a buffer, then hand it off
        i2s_channel_read(rx_handle, block.samples, sizeof(block.samples),
                         &block.bytes_read, portMAX_DELAY);
        xQueueSend(audio_queue, &block, portMAX_DELAY);
    }
}

void audio_consumer_task(void *parameter) {
    audio_block_t block;
    while (1) {
        xQueueReceive(audio_queue, &block, portMAX_DELAY);
        int n = block.bytes_read / sizeof(int32_t);
        int32_t peak = 0;
        for (int i = 0; i < n; i++) {
            int32_t v = block.samples[i] < 0 ? -block.samples[i] : block.samples[i];
            if (v > peak) peak = v;
        }
        // next: send block over UDP instead of just measuring it
    }
}

// in app_main()
audio_queue = xQueueCreate(QUEUE_LENGTH, sizeof(audio_block_t));
xTaskCreate(audio_capture_task,  "audio_capture",  4096, NULL, 5, NULL);
xTaskCreate(audio_consumer_task, "audio_consumer", 4096, NULL, 4, NULL);

What's next

  1. Send audio over UDP from the consumer task, and build the select()-based receiver on the host.
  2. Rebuild a WAV file on the host and check it by ear.
  3. Trim the data. The INMP441 is a mono, 24-bit mic, but the driver currently reads stereo 32-bit slots. Before sending, I'll keep only the active channel and pack samples down to 16-bit PCM, which cuts the bandwidth by about 4×.
  4. Measure, don't guess. Packet loss, end-to-end latency, and whether the queue ever fills up once the network is in the loop.
Status, plainly: I2S capture, the FreeRTOS producer/consumer pipeline and Wi-Fi connection all run on hardware. UDP streaming, the host receiver and WAV reconstruction aren't built yet. I'll update this page as each one works.