viniciusdias.tech

research

Recording and playback

Audio is held as raw 16-bit samples in PSRAM. There is no filesystem, no container format and no encoding step.

State

Four file-scope variables hold everything. Slots are allocated on first use: an unused slot is a null pointer with length zero, and playback of an empty slot logs a warning and returns.

play_tone/main/main.c
/* 3 slots de áudio (ponteiros para PSRAM) */
static uint8_t *slot_data[NUM_SLOTS] = { NULL, NULL, NULL };
static size_t   slot_len[NUM_SLOTS]  = { 0, 0, 0 };

/* Buffer temporário de gravação */
static uint8_t *rec_buf = NULL;
static size_t   rec_len = 0;

PSRAM is volatile. All three slots are lost when the board loses power. The firmware performs no writes to flash.

Recording

do_record() is called when SW1 has been held for LONG_PRESS_MS. It allocates rec_buf in PSRAM on first use and reuses it afterwards, reads in CHUNK_BYTES blocks, and returns when SW1 is released or MAX_SLOT_BYTES is reached.

play_tone/main/main.c — do_record()
static void do_record(void)
{
    /* Aloca buffer temporário se necessário */
    if (rec_buf == NULL) {
        rec_buf = heap_caps_malloc(MAX_SLOT_BYTES, MALLOC_CAP_SPIRAM);
        if (!rec_buf) {
            ESP_LOGE(TAG, "Falha ao alocar PSRAM para gravacao!");
            return;
        }
    }
    rec_len = 0;

    /* Habilita I2S */
    ESP_ERROR_CHECK(i2s_channel_enable(i2s_tx));
    ESP_ERROR_CHECK(i2s_channel_enable(i2s_rx));
    gpio_set_level(LED_PIN, 1);
    ESP_LOGI(TAG, "GRAVANDO... (solte SW1 para parar, max %d seg)", RECORD_MAX_SEC);

    /* Grava enquanto SW1 estiver pressionado */
    static uint8_t chunk[CHUNK_BYTES];
    size_t total = 0;

    while (gpio_get_level(BTN_PIN[0]) == 0) {
        if (total >= MAX_SLOT_BYTES) {
            ESP_LOGW(TAG, "Buffer cheio (%d seg)!", RECORD_MAX_SEC);
            break;
        }

        size_t to_read = CHUNK_BYTES;
        if (to_read > (MAX_SLOT_BYTES - total))
            to_read = MAX_SLOT_BYTES - total;

        size_t br = 0;
        esp_err_t ret = i2s_channel_read(i2s_rx, chunk, to_read,
                                         &br, pdMS_TO_TICKS(500));
        if (ret == ESP_OK && br > 0) {
            memcpy(rec_buf + total, chunk, br);
            total += br;
        }
    }

    rec_len = total;

    ESP_ERROR_CHECK(i2s_channel_disable(i2s_rx));
    ESP_ERROR_CHECK(i2s_channel_disable(i2s_tx));
    gpio_set_level(LED_PIN, 0);

    float sec = (float)rec_len / (float)(SAMPLE_RATE * 2);
    ESP_LOGI(TAG, "Gravacao: %zu bytes (%.1f seg)", rec_len, sec);
}

Both channels are enabled, not just RX. With TX and RX on one I2S port, the TX channel generates BCLK, WS and MCLK; the codec is in slave mode and transmits nothing without them. Enabling RX alone makes i2s_channel_read() return ESP_ERR_TIMEOUT with zero bytes.

chunk is static rather than a local. The FreeRTOS main task stack is around 3584 bytes, so a 4096-byte local buffer overflows it. Declaring it static places it in BSS. The same applies to the silence buffer in playback_slot().

The read timeout is 500 ms and a failed read is skipped rather than treated as an error. The loop exit condition is the button state, so releasing SW1 ends the recording regardless of bus activity.

Button helpers

Three helpers serve the main loop. wait_any_btn() drives the LED directly: it toggles every 300 ms while waiting and clears it before returning, on both the button and timeout paths.

play_tone/main/main.c
static bool any_btn_pressed(void)
{
    for (int i = 0; i < NUM_SLOTS; i++) {
        if (gpio_get_level(BTN_PIN[i]) == 0) return true;
    }
    return false;
}

/* Espera todos os botões serem soltos */
static void wait_all_released(void)
{
    while (any_btn_pressed()) {
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

/* Espera qualquer botão ser pressionado. Retorna índice 0-2, ou -1 se timeout. */
static int wait_any_btn(int timeout_ms)
{
    int elapsed = 0;
    bool led_on = false;

    while (elapsed < timeout_ms) {
        /* Pisca LED para indicar que está esperando */
        if ((elapsed % 300) == 0) {
            led_on = !led_on;
            gpio_set_level(LED_PIN, led_on ? 1 : 0);
        }

        for (int i = 0; i < NUM_SLOTS; i++) {
            if (gpio_get_level(BTN_PIN[i]) == 0) {
                vTaskDelay(pdMS_TO_TICKS(50));  /* debounce */
                if (gpio_get_level(BTN_PIN[i]) == 0) {
                    gpio_set_level(LED_PIN, 0);
                    return i;
                }
            }
        }

        vTaskDelay(pdMS_TO_TICKS(50));
        elapsed += 50;
    }

    gpio_set_level(LED_PIN, 0);
    return -1;
}

Slot assignment

After recording, app_main() calls wait_any_btn() with a ten-second timeout. The index it returns selects the destination slot.

play_tone/main/main.c — assign_recording()
static void assign_recording(int slot)
{
    if (rec_len == 0 || rec_buf == NULL) {
        ESP_LOGW(TAG, "Nada para salvar!");
        return;
    }

    /* Libera slot antigo se existir */
    if (slot_data[slot] != NULL) {
        heap_caps_free(slot_data[slot]);
    }

    /* Transfere ponteiro (evita copiar ~640KB) */
    slot_data[slot] = rec_buf;
    slot_len[slot] = rec_len;
    rec_buf = NULL;   /* será realocado na próxima gravação */
    rec_len = 0;

    float sec = (float)slot_len[slot] / (float)(SAMPLE_RATE * 2);
    ESP_LOGI(TAG, "Audio salvo no slot %d (%.1f seg)", slot + 1, sec);
}

The buffer is transferred by pointer rather than copied. The previous contents of the slot are freed, rec_buf becomes the slot buffer, and rec_buf is set to NULL so the next recording allocates a new one. This avoids a 640 000-byte memcpy and a corresponding peak in allocation.

On timeout, app_main() frees rec_buf and resets rec_len; the recording is discarded and existing slots are unchanged.

Playback

playback_slot() enables the TX channel, writes the slot in CHUNK_BYTES blocks with portMAX_DELAY, then writes one block of silence before disabling the channel.

play_tone/main/main.c — playback_slot()
static void playback_slot(int slot)
{
    if (slot_len[slot] == 0 || slot_data[slot] == NULL) {
        ESP_LOGW(TAG, "Slot %d vazio!", slot + 1);
        return;
    }

    float sec = (float)slot_len[slot] / (float)(SAMPLE_RATE * 2);
    ESP_LOGI(TAG, "Tocando slot %d (%.1f seg)...", slot + 1, sec);

    ESP_ERROR_CHECK(i2s_channel_enable(i2s_tx));
    gpio_set_level(LED_PIN, 1);

    size_t offset = 0;
    while (offset < slot_len[slot]) {
        size_t to_write = CHUNK_BYTES;
        if (to_write > (slot_len[slot] - offset))
            to_write = slot_len[slot] - offset;

        size_t bw = 0;
        i2s_channel_write(i2s_tx, slot_data[slot] + offset, to_write,
                          &bw, portMAX_DELAY);
        offset += bw;
    }

    /* Flush DMA */
    static uint8_t silence[CHUNK_BYTES];
    memset(silence, 0, sizeof(silence));
    size_t bw;
    i2s_channel_write(i2s_tx, silence, sizeof(silence), &bw, portMAX_DELAY);

    ESP_ERROR_CHECK(i2s_channel_disable(i2s_tx));
    gpio_set_level(LED_PIN, 0);
    ESP_LOGI(TAG, "Slot %d finalizado.", slot + 1);
}

The silence block flushes the DMA buffer. Disabling the channel directly after the last sample discards what is still queued in the descriptors, truncating the end of playback.

Main loop

app_main() polls the three buttons on a 20 ms cycle after initialising GPIO, I2C, the codec and I2S. There are no interrupts and no additional tasks. Each button read is debounced by a 50 ms delay followed by a second read.

play_tone/main/main.c — app_main()
    while (1) {

        /* --- SW1 pressionado: detecta curto vs longo --- */
        if (gpio_get_level(BTN_PIN[0]) == 0) {
            vTaskDelay(pdMS_TO_TICKS(50));  /* debounce */
            if (gpio_get_level(BTN_PIN[0]) == 0) {

                /* Espera até 1s para decidir: curto ou longo? */
                int held = 50;  /* já esperamos 50ms no debounce */
                while (gpio_get_level(BTN_PIN[0]) == 0 && held < LONG_PRESS_MS) {
                    vTaskDelay(pdMS_TO_TICKS(50));
                    held += 50;
                }

                if (held >= LONG_PRESS_MS) {
                    /* === LONG PRESS: GRAVAR === */
                    do_record();
                    wait_all_released();

                    if (rec_len > 0) {
                        ESP_LOGI(TAG, "Pressione SW1/SW2/SW3 para salvar no slot...");
                        int slot = wait_any_btn(10000);  /* 10s timeout */
                        if (slot >= 0) {
                            assign_recording(slot);
                        } else {
                            ESP_LOGW(TAG, "Timeout! Gravacao descartada.");
                            /* Libera buffer temporário */
                            if (rec_buf) {
                                heap_caps_free(rec_buf);
                                rec_buf = NULL;
                            }
                            rec_len = 0;
                        }
                        wait_all_released();
                    }
                } else {
                    /* === SHORT PRESS: TOCAR SLOT 1 === */
                    wait_all_released();
                    playback_slot(0);
                }
            }
        }

        /* --- SW2 pressionado: tocar slot 2 --- */
        if (gpio_get_level(BTN_PIN[1]) == 0) {
            vTaskDelay(pdMS_TO_TICKS(50));
            if (gpio_get_level(BTN_PIN[1]) == 0) {
                wait_all_released();
                playback_slot(1);
            }
        }

        /* --- SW3 pressionado: tocar slot 3 --- */
        if (gpio_get_level(BTN_PIN[2]) == 0) {
            vTaskDelay(pdMS_TO_TICKS(50));
            if (gpio_get_level(BTN_PIN[2]) == 0) {
                wait_all_released();
                playback_slot(2);
            }
        }

        vTaskDelay(pdMS_TO_TICKS(20));
    }
}

The short-versus-long distinction for SW1 is measured by counting: after the debounce, the loop samples every 50 ms until the button is released or LONG_PRESS_MS is reached. Below the threshold it plays slot 1; at or above it, it records.

Extension points

The behaviours below are each controlled by one constant or call site. Anything not listed requires changes across more than one function.

Constant or call site Controls
NUM_SLOTS number of slots; BTN_PIN must grow to match, and there are three physical buttons
RECORD_MAX_SEC maximum recording length; scales MAX_SLOT_BYTES and PSRAM use linearly
SAMPLE_RATE sample rate; also affects the codec Fs register 0x0D, which is set to Fs = 256
LONG_PRESS_MS hold threshold that separates play from record on SW1
CHUNK_BYTES I2S transfer block size; the two static buffers scale with it
wait_any_btn(10000) time to choose a destination slot after recording
vTaskDelay(20) polling period of the main loop

Persisting audio across power cycles is not a constant change: it requires a filesystem such as SPIFFS or LittleFS, a partition table entry for it, and write and read paths around assign_recording() and startup.