Why build it

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.
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
- Send audio over UDP from the consumer task, and build the
select()-based receiver on the host. - Rebuild a WAV file on the host and check it by ear.
- 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×.
- Measure, don't guess. Packet loss, end-to-end latency, and whether the queue ever fills up once the network is in the loop.