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.
/* 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.
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.
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.
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.
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.
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.