Recovery and restoration service:frontea online,corp.

Recovery and restoration service:frontea online,corp.

Deep Abyss Audio (4) Installing the I2S Tachometer: Building a Pro‑Audio USB DAC with an ESP32‑S3 Core

‹ 2025/12/26 ›

Hello again.

In this fourth chapter, we attach a tachometer to the I2S engine—turning it into a measurement instrument.


“Uh… what exactly are we doing?”

We’re introducing a pulse counter to monitor the rotational speed—

in other words, the MCLK frequency—of the I2S engine.


“Doesn’t that mean more circuits and parts?”

Nope.

We hack it directly through the ESP32‑S3 GPIO Matrix, wiring the I2S MCLK output back into an internal counter.

(Ridiculously convenient.)


“And then what?”

We use this measurement as the actual feedback value for the USB Audio Class (UAC) feedback endpoint.


“Wait, isn’t that something TinyUSB already had?”

Let’s not worry about that for now.

First, we attach the counter and build the framework shown below.



Ohhh—this looks good. Very cool.

“Feels like we can launch this thing into orbit now!”

As the full picture comes together, my shameless MOS-copying project is starting to feel like actual work for P(j)AXA.

Sweating a bit.


Yes—

the command tower “CCC”, essential for any Pro‑Audio‑spec (space‑exploration‑rocket‑grade) system, is finally taking shape


Notes on Abbreviations

Some of the shorthand (my private jargon) in the diagram may look cryptic, so here’s a quick glossary:

  • SR — Current Sample Rate
  • SV — Supply Volume
  • WL — Water Level
  • SRC — SRC Effective Count

(Number of times Sample Rate Conversion actually kicked in)

These are critical measurement values monitored and controlled by CCC.

Without them, you simply cannot call it Pro Audio spec.

(You won’t get hired by PAXA. Pain.)


Choosing the Counter

To internally monitor a GPIO output on the ESP32‑S3, we have two major hardware options:

  • PCNT (Pulse Counter)
  • RMT (Remote Control Peripheral)

(Both require no external circuitry.)


PCNT

  • Detection: Pulse count
  • Range: 80 MHz / 2 ≈ 40 MHz
  • Practical limit: 20 MHz (24.576 MHz = OK)
  • Detects: Positive / Negative edges

RMT

  • Detection: Pulse width
  • Range: ~80 MHz
  • Practical limit: 20 MHz
  • Detects: Edges, waveform characteristics, jitter


RMT is more capable, depending on how you use it.

But for this role, I’m choosing PCNT.

The decisive reasons for not using RMT are:

  • “Too much hassle.”
  • “Don’t need that much.”

That’s it.


Choosing the Counter Timer

Next, we select the timer that drives the counter.

There are three main options:


ESP Timer

  • Type: FreeRTOS software timer
  • Count: Unlimited (software)
  • Precision: High jitter
  • Flexibility: Very high

GP Timer

  • Type: General‑purpose hardware timer
  • Count: 4 units
  • Precision: Low jitter
  • Flexibility: Limited (*)

Timer Group

  • Type: Legacy hardware timer
  • Precision: Low jitter


So which one should we use…?

Realistically, it’s between ESP Timer and GP Timer.

I’ll start with ESP Timer and switch later if needed.


A note on GP Timer’s “limited” flexibility

GP Timer offers better precision, but:

  • It triggers hardware interrupts
  • The interrupt context does not include FPU support
  • Meaning: integer math only inside the timer callback
  • If you want to do more complex processing…

you end up relaying commands via a delayed ISR anyway

In the end, running CCC’s core control logic on GP Timer would likely give us no better precision than ESP Timer.

Sweating again.


Monitor Initialization Module

Now let’s implement the startup sequence for the monitoring module.

void init_monitor(void)

{

    esp_err_t ret;

    ESP_LOGI(TAG, "init_monitor enter");


    // Output I2S0 MCLK to GPIO25

    //gpio_matrix_out(PIN_MCK, I2S0_MCLK_OUT_IDX, false, false);

    //gpio_set_direction(PIN_MCK, GPIO_MODE_OUTPUT);


    // PCNT configuration

    pcnt_config_t pcnt_config = {

        .pulse_gpio_num = PIN_MCK, // MCKを入力

        .ctrl_gpio_num = PCNT_PIN_NOT_USED,

        .channel = PCNT_CHANNEL_0,

        .unit = SYNCLK_PCNT_UNIT,

        .pos_mode = PCNT_COUNT_INC,

        .neg_mode = /*PCNT_COUNT_INC*/PCNT_COUNT_DIS, // Disable negative-edge count to avoid phase‑dependent errors

        .lctrl_mode = PCNT_MODE_KEEP,

        .hctrl_mode = PCNT_MODE_KEEP,

        .counter_h_lim = INT16_MAX, // INT16_MAX

        .counter_l_lim = 0,

    };

    pcnt_unit_config(&pcnt_config);


    // Explicitly disable filter

    pcnt_set_filter_value(SYNCLK_PCNT_UNIT, 0);

    pcnt_filter_disable(SYNCLK_PCNT_UNIT);


    // Clear counter only during initialization

    pcnt_counter_pause(SYNCLK_PCNT_UNIT);

    pcnt_counter_clear(SYNCLK_PCNT_UNIT);

    pcnt_counter_resume(SYNCLK_PCNT_UNIT);


    // Register ESPTimer

    const esp_timer_create_args_t timer_args = {

        .callback = &monitor_task_esp,

        .name = "monitor_task_esp",

        .dispatch_method = ESP_TIMER_TASK,

    };


    ret = esp_timer_create(&timer_args, &g_esptimer);

    ESP_LOGI(TAG, "%s to create %s", ret == ESP_OK?"Succeeded":"Failed", "monitor_task_esp");

    

    ret = esp_timer_start_periodic(g_esptimer, 1000); // 1 ms interval

    ESP_LOGI(TAG, "%s to start monitor_task_esp per=%dus", ret == ESP_OK?"Succeeded":"Failed", 1000);

}


Choosing the Measurement Pin

Technically, you can use any GPIO for measurement.

But what we want is the highest‑precision MCLK current rate.


So the correct answer is simple:

connect the counter directly to the MCLK pin.


No tricks. No abstractions.

Just measure the real thing.


The I2S Initialization Trap

And here we step into yet another trap.

The I2S internal GPIO MUX swamp.

Do not enter.

If you step in, you will never move forward again.


Strict Startup Order

You must follow this order:

init_monitor()  =>  i2s_init()

This is absolutely critical.


void app_main(void)

{

・・・

    // Initialize monitor BEFORE I2S

    // The I2S STD driver monopolizes IO_MUX,

    // so PCNT must be configured before i2s_init()

    init_monitor();


    // Init I2S before USB for stable output clocking

    i2s_init();

・・・

}


If you violate this order, the GPIO MUX will lock you out,

and the PCNT will never see MCLK.


The MCLK Rate Trap

This one is not a trap—just an easy oversight.

You must adjust the MCLK multiplier based on:

  • desired playback sample rate
  • DAC datasheet
  • PCNT measurement limitations

Insert something like this into i2s_init():


    if (uac_as_quality.sample_rate > 96000)

        std_cfg.clk_cfg.mclk_multiple = I2S_MCLK_MULTIPLE_128;

    else

        std_cfg.clk_cfg.mclk_multiple = I2S_MCLK_MULTIPLE_256;


At 192 kHz, you are forced to drop to:

mclk_multiple = 128


Because the PCNT cannot reliably count a 49.152 MHz MCLK.

Your own counter betrays you—

and 256× falls in battle.

Painful.


Verifying the Counter

Finally, let’s confirm that the counter is actually working.



“Oh? Is it already done?”

Yes.

In fact, the shameless MOS clone is 99% complete.

The photo shows the system running with the current PAXA tuning parameters.


The key point this time is:

*96003.21


That’s the measured sample rate.

Pretty impressive accuracy.


Once you tune the parameters SR, SV, WL, SRC to their ideal values,

your Pro‑Audio‑spec MOS clone will be complete.

You’re already very close.