Recovery and restoration service:frontea online,corp.

Recovery and restoration service:frontea online,corp.

Deep Abyss Audio (6) Building the DDC Buffer: DIY Pro‑Audio PopoDAC (USB DAC)

‹ 2025/12/31 ›

Hello.

This is Episode 6, where we build the DDC buffer.


Up to the previous episode, we learned that PopoDAC is being developed using UAC1 asynchronous feedback.

Starting from this episode, several elements that get to the heart of PopoDAC’s design philosophy will finally become visible.


The first step is to prepare the “Buffer to Buffer” mechanism in a way that perfectly aligns with PopoDAC’s philosophy.




DDC Core and the Need for Buffer‑to‑Buffer

PopoDAC’s DDC core looks like the diagram shown earlier.

At the center of this system, we need a buffering mechanism specifically designed for Buffer‑to‑Buffer transfer.


Lock-Free Ring Buffer

There are many ways to implement buffering:

  • Simple single buffer
  • Double buffer
  • FreeRTOS queue
  • FreeRTOS ring buffer
  • Fully custom implementation
  • Or even skipping the DDC buffer entirely by doing DMA‑to‑DMA

Among these, PopoDAC has only one viable choice:

A custom-built Lock‑Free Ring Buffer.

Nothing else meets the requirements.

fast_ringbuffer.c

Here is the implementation:

#pragma once


#ifdef __cplusplus

 extern "C" {

#endif


#include "stdio.h"

#include "esp_attr.h"


typedef struct {

    uint8_t *buf;

    int16_t size;

    volatile size_t head;

    volatile size_t tail;

    uint8_t lastsample[4];

} FastRingBuffer;


static inline void rb_init(FastRingBuffer *rb, uint8_t *ptrbuf, int16_t sizebuf) 

{

    rb->buf = ptrbuf;

    rb->size = sizebuf;

    rb->head = rb->tail = 0;


    *(int32_t*)(&rb->lastsample[0]) = 0;

}


static inline void rb_dropall(FastRingBuffer *rb) 

{

    rb->head = rb->tail = 0;

    *(int32_t*)(&rb->lastsample[0]) = 0;

}


static inline void rb_drop(FastRingBuffer *rb, size_t len) 

{

    size_t written = 0;

    for (size_t i = 0; i < len; i ) {

        size_t next = (rb->head 1) % rb->size;

        if (next == rb->tail) break; // buffer full

        //rb->buf[rb->head] = data[i];

        rb->head = next;

        written ;

    }

}


static inline size_t rb_write(FastRingBuffer *rb, const uint8_t *data, size_t len) 

{

    size_t written = 0;

    for (size_t i = 0; i < len; i ) {

        size_t next = (rb->head 1) % rb->size;

        if (next == rb->tail) break; // buffer full

        rb->buf[rb->head] = data[i];

        rb->head = next;

        written ;

    }


    // capture last sample

    if (len>4)

        *(int32_t*)(&rb->lastsample[0]) = *(int32_t*)(&data[len-4]);


    return written;

}


static inline size_t rb_read(FastRingBuffer *rb, uint8_t *data, size_t len) 

{

    size_t read = 0;

    while (rb->tail != rb->head && read < len) {

        data[read ] = rb->buf[rb->tail];

        rb->tail = (rb->tail 1) % rb->size;

    }

    return read;

}


static inline size_t rb_available(FastRingBuffer *rb) 

{

    return (rb->head >= rb->tail) ? (rb->head - rb->tail) : (rb->size - rb->tail rb->head);

}


#ifdef __cplusplus

}

#endif


This is a very standard ring buffer.

There’s nothing particularly surprising or exotic inside.


If anything, the only small twist is that the actual buffer memory is not stored inside the structure, but passed in as a pointer.


Lock-Free Ring Span

And here is the second major component:

a large functional extension to the fast ring buffer, called Fast Ring Span.

This is where things may suddenly look confusing if your intuition isn’t warmed up:

  • “Huh? What is this?”
  • “What does this tool even do?”
  • “Do we really need this?”

Yes.

This is absolutely essential.

It is one of the core tools that makes PopoDAC what it is.


Fast Ring Span — Extended API for PopoDAC


#pragma once


#ifdef __cplusplus

extern "C" {

#endif


#include "fast_ringbuffer.h"


/* ============================================================

   FastRingBuffer Extended API

   - Byte-level: existing rb_write / rb_read / rb_available

   - Sample-level: for ASRC / format conversion

   - Frame-level: for I2S DMA (MASTER timeline)

   ============================================================ */


extern size_t rb_available(FastRingBuffer *rb);


/* ------------------------------------------------------------

   Common Span Structure

   ------------------------------------------------------------ */

typedef struct {

    uint8_t *ptr1;

    size_t len1;

    uint8_t *ptr2;

    size_t len2;

} RbSpan;


/* ============================================================

   1. Sample-Level API (for ASRC / CVT)

   ============================================================ */

static inline size_t rb_read_samples(

    FastRingBuffer *rb,

    uint8_t *dst,

    size_t sample_count,

    size_t sample_bytes

){

    size_t need = sample_count * sample_bytes;

    size_t avail = rb_available(rb);


    if (avail < sample_bytes)

        return 0; // cannot read even one sample


    if (avail < need)

        need = avail - (avail % sample_bytes);


    size_t read = 0;

    while (read < need) {

        dst[read ] = rb->buf[rb->tail];

        rb->tail = (rb->tail 1) % rb->size;

    }

    return read / sample_bytes;

}


static inline size_t rb_write_samples(

    FastRingBuffer *rb,

    const uint8_t *src,

    size_t sample_count,

    size_t sample_bytes

){

    size_t need = sample_count * sample_bytes;

    size_t written = 0;


    for (size_t i = 0; i < need; i ) {

        size_t next = (rb->head 1) % rb->size;

        if (next == rb->tail) break;

        rb->buf[rb->head] = src[i];

        rb->head = next;

        written ;

    }


    // NOTE: lastsample assumes 32-bit samples

    if (sample_bytes == 4 && written >= 4)

        *(int32_t*)rb->lastsample = *(int32_t*)&src[written - 4];


    return written / sample_bytes;

}


/* ============================================================

   2. Frame-Level API (for I2S DMA)

   ============================================================ */

static inline RbSpan rb_peek_frames(

    FastRingBuffer *rb,

    size_t frame_count,

    size_t frame_bytes

){

    RbSpan span = {0};


    size_t need = frame_count * frame_bytes;

    size_t avail = rb_available(rb);


    if (avail < frame_bytes)

        return span; // not even one frame


    if (avail < need)

        need = avail - (avail % frame_bytes);


    size_t tail = rb->tail;

    size_t size = rb->size;


    size_t first_chunk = size - tail;


    if (first_chunk >= need) {

        span.ptr1 = &rb->buf[tail];

        span.len1 = need;

        span.ptr2 = NULL;

        span.len2 = 0;

    } else {

        span.ptr1 = &rb->buf[tail];

        span.len1 = first_chunk;

        span.ptr2 = &rb->buf[0];

        span.len2 = need - first_chunk;

    }


    return span;

}


static inline void rb_advance_frames(

    FastRingBuffer *rb,

    size_t frame_count,

    size_t frame_bytes

){

    size_t adv = frame_count * frame_bytes;

    rb->tail = (rb->tail adv) % rb->size;

}


// Helper: get pointer to "frame index" inside span

static inline uint8_t* rbspan_frame_ptr(

    const RbSpan *span,

    size_t frame_index,

    size_t frame_bytes

){

    size_t byte_off = frame_index * frame_bytes;


    if (byte_off < span->len1) {

        return span->ptr1 byte_off;

    } else {

        size_t off2 = byte_off - span->len1;

        return span->ptr2 off2;

    }

}


#ifdef __cplusplus

}

#endif


What is Fast Ring Span, really?

Fast Ring Span is the bridge between:

  • the byte‑level world (USB packets, feedback timing)
  • the sample‑level world (ASRC, format conversion)
  • the frame‑level world (I2S DMA, master timeline)


Buffer Standby

Once the buffer definitions are ready, all that remains is to create the instances and put them on standby.

init_buffer()

// Buffer initialization

void init_buffer(void)

{

#if (TRANSFER_MODEL & TRANSFER_BUFFER_HEAPALLOC)

    // Create heap-allocated buffer

    g_usb_buf = heap_caps_malloc(TRANSFER_BUFFER_SIZE*TRANSFER_BUFFER_COUNT, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);

    memset(g_usb_buf, 0, TRANSFER_BUFFER_SIZE*TRANSFER_BUFFER_COUNT);

    ESP_LOGI(TAG, "init_buffer usb_buf_ptr=%p, size=%d", g_usb_buf, TRANSFER_BUFFER_SIZE*TRANSFER_BUFFER_COUNT);

    ESP_LOGI(TAG, "init_buffer silent_buf_ptr=%p, size=%d", g_silence, MAX_PACKET_SIZE);

    ESP_LOGI(TAG, "init_buffer pcm_buf_ptr=%p, size=%d", g_pcm_buf, MAX_PACKET_SIZE<

#endif


#if (TRANSFER_MODEL & TRANSFER_BUFFER_FASTRING)

    // Create ring buffer

    rb_init(&g_audio_ring, g_usb_buf, TRANSFER_BUFFER_SIZE*TRANSFER_BUFFER_COUNT);

    ESP_LOGI(TAG, "init_buffer rb_ptr=%p, size=%d", &g_audio_ring, g_audio_ring.size);

#endif

}


It’s almost too ordinary to bother pasting here…

but yes, here it is.

All we’re doing is:

  • allocating the ring buffer on the heap
  • initializing it
  • logging the pointer and size

That’s it.

But—

the size matters.

In PopoDAC, buffer sizing is not a trivial detail; it directly affects timing stability, DMA behavior, and the entire DDC pipeline.

So it’s worth paying attention to.

Application Standby

With everything prepared up to this point, the application can finally be placed on standby.

Let’s take a look at the current application startup sequence.

app_main()

void app_main(void)

{

    BaseType_t ret_val = 0;


    // ESP_LOG configuration

#if ESP_LOG_RANK >= 2

    esp_log_level_set(TAG, ESP_LOG_DEBUG);

    ESP_LOGD(TAG, "debug message");

#endif


    // Initialize buffers

    init_buffer();


    // Load parameters

    init_params();


    // Initialize OLED

    oled_init();


    // Initialize monitor

    // Must avoid IO_MUX conflicts with I2S STD driver

    // If using PCNT to count pulses such as MCLK, ensure i2s_init() is done first

    init_monitor();


    // Init I2S before USB for stable output clocking

    i2s_init();


    // Init TinyUSB core

    usb_init();


    // Start TinyUSB task

    ret_val = xTaskCreatePinnedToCore(usb_task, "ForTinyUSB", TASK_TINYUSB_STACKSIZE, NULL, TASK_TINYUSB_PRIORITY, NULL, TASK_TINYUSB_CORE);

    ESP_LOGI(TAG, "%s to create %s", ret_val == pdPASS?"Succeeded":"Failed", "usb_task");


#if (TRANSFER_MODEL & TRANSFER_TASK_DUAL) || (TRANSFER_MODEL & TRANSFER_TASK_I2S_CALLBACK)

    // Start UAC Read task

    ret_val = xTaskCreatePinnedToCore(uac_read_task, "ForUacRead", TASK_UAC_READ_STACKSIZE, NULL, TASK_UAC_READ_PRIORITY,

                                          NULL, TASK_UAC_READ_CORE);

    ESP_LOGI(TAG, "%s to create %s", ret_val == pdPASS?"Succeeded":"Failed", "uac_read_task");

#endif


#if (TRANSFER_MODEL & TRANSFER_TASK_DUAL) || (TRANSFER_MODEL & TRANSFER_TASK_UAC_CALLBACK) || (TRANSFER_MODEL & TRANSFER_TASK_DUAL_CALLBACK) 

    // Start I2S Write task

    ret_val = xTaskCreatePinnedToCore(i2s_write_task, "ForI2sWrite", TASK_I2S_WRITE_STACKSIZE, NULL, TASK_I2S_WRITE_PRIORITY, NULL, TASK_I2S_WRITE_CORE);

    ESP_LOGI(TAG, "%s to create %s", ret_val == pdPASS?"Succeeded":"Failed", "i2s_write_task");

#endif


#if (TRANSFER_MODEL & TRANSFER_TASK_SINGLE)

    // Start DDC task

    ret_val = xTaskCreatePinnedToCore(ddc_task, "ForDDC", TASK_SPEAKER_STACKSIZE, NULL, TASK_SPEAKER_PRIORITY, NULL, TASK_SPEAKER_CORE);

    ESP_LOGI(TAG, "%s to create %s", ret_val == pdPASS?"Succeeded":"Failed", "ddc_task");

#endif


#if (TRANSFER_MODEL&TRANSFER_MSCLK_GPTIMER)

    // Start Monitor task

    ret_val = xTaskCreatePinnedToCore(monitor_task_gpt, "ForTimer", TASK_GPTIMER_STACKSIZE, NULL, TASK_GPTIMER_PRIORITY, &g_monitor_task_handle, TASK_GPTIMER_CORE);

    ESP_LOGI(TAG, "%s to create %s", ret_val == pdPASS?"Succeeded":"Failed", "monitor_task_gpt");

#endif


    // Start OLED task

    ret_val = xTaskCreatePinnedToCore(display_task, "ForOLED", 4096, NULL, 1, NULL, 1);

    ESP_LOGI(TAG, "%s to create %s", ret_val == pdPASS?"Succeeded":"Failed", "display_task");


    // Configure user button action

    gpio_config_t io_conf = {

        .pin_bit_mask = (1ULL << MODE_CHANGE_PIN),

        .mode = GPIO_MODE_INPUT,

        .pull_up_en = GPIO_PULLUP_ENABLE,

        .pull_down_en = GPIO_PULLDOWN_DISABLE,

        .intr_type = GPIO_INTR_NEGEDGE   // interrupt on press (HIGH→LOW)

    };

    gpio_config(&io_conf);


    // Install interrupt service

    gpio_install_isr_service(0);

    gpio_isr_handler_add(MODE_CHANGE_PIN, modechange_isr_handler, NULL);

    ESP_LOGI(TAG, "%s to start %s", ret_val == pdPASS?"Succeeded":"Failed", "modechange_isr_handler");


    // Notify that the application has started

    g_app_started = true;


What’s happening here?

Yes — it’s the familiar initialization sequence:

  • - buffers
  • - parameters
  • - OLED
  • - monitor
  • - I2S
  • - USB
  • - tasks for UAC, I2S, DDC, monitor, OLED
  • - button interrupt
  • - startup complete flag

Next time, we will finally dive into:

The DDC core — the component that embodies PopoDAC’s philosophy and worldview.