54d1a1ec6e
CI / platformio (pull_request) Failing after 4m10s
IDF5 i2s_channel_init_std_mode() constitutes full-duplex ONLY when TX and RX std_cfg are byte-for-byte identical (memcmp). When din/dout differ between the two calls, the driver silently moves RX to I2S_NUM_1 which has no BCLK/WS routing, producing permanent zeros on i2s_channel_read(). Fix: use the same i2s_std_config_t for both TX and RX init calls, with dout=GPIO26 and din=GPIO35 both set. IDF handles GPIO direction internally. Also clean up ES8388 register sequence: - ADCCONTROL2 = 0x00 (LIN1/RIN1 differential, LINE IN header) - ADCCONTROL6 = 0x00 (clear ADCSMUTE, reset default was 0x30) - ADCPOWER = 0x00 (full ADC power-up, was 0x09) - DACCONTROL21 = 0x80 (DAC+ADC normal mode, not line bypass 0xC0) Verified: peak=2593, rms=2482 over 48000 samples (3s @ 16kHz).
823 lines
31 KiB
C
823 lines
31 KiB
C
/*
|
||
* audio.c — I2S + ES8388 audio driver for the PLIP voice annex (Phase A/D).
|
||
*
|
||
* Init order:
|
||
* 1. es8388_init() — I2C master + codec register sequence
|
||
* 2. i2s_new_channel() — allocate TX (speaker) + RX (mic) channels
|
||
* 3. i2s_channel_init_std_mode() — configure Philips 16-bit stereo @ 16kHz
|
||
* 4. i2s_channel_enable()
|
||
* 5. gpio PA_ENABLE = 1 (already done by es8388_init)
|
||
*
|
||
* Threading:
|
||
* - audio_play_tone() is blocking — call from a task, not the I2S callback.
|
||
* - audio_play_async() posts to a FreeRTOS queue; a worker task drains it.
|
||
* - The I2S handle is published via audio_spk_handle() for cmd_exec.
|
||
*/
|
||
|
||
#include "audio.h"
|
||
#include "board_config.h"
|
||
#include "es8388.h"
|
||
#include "phone.h"
|
||
|
||
#include <inttypes.h>
|
||
#include <math.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
|
||
#include "driver/gpio.h"
|
||
#include "esp_heap_caps.h"
|
||
#include "esp_log.h"
|
||
#include "esp_spiffs.h"
|
||
#include "esp_vfs_fat.h"
|
||
#include "driver/i2s_std.h"
|
||
#include "driver/sdspi_host.h"
|
||
#include "driver/spi_common.h"
|
||
#include "sdmmc_cmd.h"
|
||
#include "freertos/FreeRTOS.h"
|
||
#include "freertos/queue.h"
|
||
#include "freertos/task.h"
|
||
|
||
#define TAG "audio"
|
||
|
||
/* ── Audio parameters ────────────────────────────────────────────────────── */
|
||
#define SAMPLE_RATE PLIP_SAMPLE_RATE /* 16000 */
|
||
#define BITS PLIP_BITS_PER_SAMPLE /* 16 */
|
||
#define CHANNELS PLIP_CHANNELS /* 2 = stereo (ES8388 I2S) */
|
||
|
||
/* ── WAV header ──────────────────────────────────────────────────────────── */
|
||
#define WAV_MIN_HEADER 44
|
||
|
||
typedef struct {
|
||
uint16_t audio_format;
|
||
uint16_t channels;
|
||
uint32_t sample_rate;
|
||
uint16_t bits_per_sample;
|
||
uint32_t data_offset;
|
||
uint32_t data_size;
|
||
} wav_info_t;
|
||
|
||
/* ── Async play queue ────────────────────────────────────────────────────── */
|
||
typedef enum { CMD_PLAY, CMD_STOP, CMD_RING } audio_cmd_kind_t;
|
||
|
||
typedef struct {
|
||
audio_cmd_kind_t kind;
|
||
char path[160];
|
||
} audio_cmd_t;
|
||
|
||
static QueueHandle_t s_queue;
|
||
static i2s_chan_handle_t s_spk_handle = NULL;
|
||
static i2s_chan_handle_t s_mic_handle = NULL;
|
||
static volatile bool s_stop_req = false;
|
||
static bool s_sd_mounted = false;
|
||
static bool s_spiffs_mounted = false;
|
||
|
||
/* ── SPIFFS lazy mount ───────────────────────────────────────────────────── */
|
||
|
||
static void ensure_spiffs_audio(void)
|
||
{
|
||
if (s_spiffs_mounted) return;
|
||
esp_vfs_spiffs_conf_t conf = {
|
||
.base_path = "/spiffs",
|
||
.partition_label = "storage",
|
||
.max_files = 8,
|
||
.format_if_mount_failed = false,
|
||
};
|
||
esp_err_t ret = esp_vfs_spiffs_register(&conf);
|
||
if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE /* already mounted */) {
|
||
s_spiffs_mounted = true;
|
||
ESP_LOGI(TAG, "SPIFFS mounted at /spiffs");
|
||
} else {
|
||
ESP_LOGW(TAG, "SPIFFS mount failed: %s", esp_err_to_name(ret));
|
||
}
|
||
}
|
||
|
||
/* ── SD card init (best-effort) ──────────────────────────────────────────── */
|
||
|
||
static void ensure_sd_mounted(void)
|
||
{
|
||
if (s_sd_mounted) return;
|
||
|
||
sdmmc_host_t host = SDSPI_HOST_DEFAULT();
|
||
host.slot = SPI3_HOST;
|
||
|
||
spi_bus_config_t bus_cfg = {
|
||
.mosi_io_num = PLIP_SD_MOSI,
|
||
.miso_io_num = PLIP_SD_MISO,
|
||
.sclk_io_num = PLIP_SD_SCK,
|
||
.quadwp_io_num = -1,
|
||
.quadhd_io_num = -1,
|
||
.max_transfer_sz = 4000,
|
||
};
|
||
esp_err_t ret = spi_bus_initialize(SPI3_HOST, &bus_cfg, SDSPI_DEFAULT_DMA);
|
||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||
ESP_LOGW(TAG, "spi_bus_initialize: %s", esp_err_to_name(ret));
|
||
return;
|
||
}
|
||
|
||
sdspi_device_config_t slot = SDSPI_DEVICE_CONFIG_DEFAULT();
|
||
slot.gpio_cs = PLIP_SD_CS;
|
||
slot.host_id = SPI3_HOST;
|
||
|
||
esp_vfs_fat_sdmmc_mount_config_t mount_cfg = {
|
||
.format_if_mount_failed = false,
|
||
.max_files = 4,
|
||
.allocation_unit_size = 16 * 1024,
|
||
};
|
||
sdmmc_card_t *card = NULL;
|
||
ret = esp_vfs_fat_sdspi_mount(PLIP_SD_MOUNT, &host, &slot, &mount_cfg, &card);
|
||
if (ret == ESP_OK) {
|
||
s_sd_mounted = true;
|
||
ESP_LOGI(TAG, "SD card mounted at %s", PLIP_SD_MOUNT);
|
||
sdmmc_card_print_info(stdout, card);
|
||
} else {
|
||
ESP_LOGW(TAG, "SD card mount failed: %s", esp_err_to_name(ret));
|
||
}
|
||
}
|
||
|
||
/* ── WAV parser ──────────────────────────────────────────────────────────── */
|
||
|
||
static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *out)
|
||
{
|
||
if (len < WAV_MIN_HEADER) return ESP_ERR_INVALID_SIZE;
|
||
if (memcmp(buf, "RIFF", 4) != 0 || memcmp(buf + 8, "WAVE", 4) != 0)
|
||
return ESP_ERR_INVALID_ARG;
|
||
|
||
size_t pos = 12;
|
||
bool got_fmt = false, got_data = false;
|
||
while (pos + 8 <= len && !(got_fmt && got_data)) {
|
||
uint32_t chunk_size;
|
||
memcpy(&chunk_size, buf + pos + 4, 4);
|
||
if (memcmp(buf + pos, "fmt ", 4) == 0 && chunk_size >= 16) {
|
||
memcpy(&out->audio_format, buf + pos + 8, 2);
|
||
memcpy(&out->channels, buf + pos + 10, 2);
|
||
memcpy(&out->sample_rate, buf + pos + 12, 4);
|
||
memcpy(&out->bits_per_sample,buf + pos + 22, 2);
|
||
got_fmt = true;
|
||
} else if (memcmp(buf + pos, "data", 4) == 0) {
|
||
out->data_offset = (uint32_t)(pos + 8);
|
||
out->data_size = chunk_size;
|
||
got_data = true;
|
||
}
|
||
pos += 8 + chunk_size;
|
||
if (chunk_size == 0) break;
|
||
}
|
||
if (!got_fmt || !got_data) return ESP_ERR_NOT_FOUND;
|
||
if (out->audio_format != 1) return ESP_ERR_NOT_SUPPORTED; /* not PCM */
|
||
return ESP_OK;
|
||
}
|
||
|
||
/* ── Streaming helpers ───────────────────────────────────────────────────── */
|
||
|
||
/* Stream raw PCM-16 data to the speaker I2S channel in chunks. */
|
||
static void stream_pcm(const uint8_t *data, size_t byte_len)
|
||
{
|
||
size_t offset = 0;
|
||
while (!s_stop_req && offset < byte_len) {
|
||
size_t chunk = (byte_len - offset < 2048) ? (byte_len - offset) : 2048;
|
||
size_t written = 0;
|
||
esp_err_t ret = i2s_channel_write(s_spk_handle,
|
||
data + offset, chunk,
|
||
&written, pdMS_TO_TICKS(500));
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
|
||
break;
|
||
}
|
||
offset += chunk;
|
||
}
|
||
}
|
||
|
||
/* Play WAV from an in-memory buffer. */
|
||
static void play_wav_buf(const uint8_t *buf, size_t len)
|
||
{
|
||
wav_info_t wi = {0};
|
||
esp_err_t ret = parse_wav_header(buf, len, &wi);
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGW(TAG, "WAV parse error: %s", esp_err_to_name(ret));
|
||
audio_play_tone(880.0f, 200);
|
||
return;
|
||
}
|
||
if (wi.bits_per_sample != 16) {
|
||
ESP_LOGW(TAG, "WAV: %d-bit not supported (need 16-bit)", wi.bits_per_sample);
|
||
return;
|
||
}
|
||
ESP_LOGI(TAG, "WAV: %"PRIu32" Hz %d-bit %d ch, %"PRIu32" bytes PCM",
|
||
wi.sample_rate, wi.bits_per_sample, wi.channels, wi.data_size);
|
||
stream_pcm(buf + wi.data_offset, wi.data_size);
|
||
}
|
||
|
||
/* WAV streaming chunk size — keeps heap usage well under 8 KB. */
|
||
#define PLAY_CHUNK_BYTES 4096
|
||
|
||
/* Play WAV from filesystem (SPIFFS or SD) using streaming I/O.
|
||
* Only the WAV header (≤512 bytes) is buffered; PCM is piped directly
|
||
* to I2S in PLAY_CHUNK_BYTES slices, avoiding any large heap allocation.
|
||
* Paths starting with /spiffs/ are read from SPIFFS; all others use SD. */
|
||
static void play_wav_file(const char *path)
|
||
{
|
||
bool is_spiffs = (strncmp(path, "/spiffs/", 8) == 0 || strncmp(path, "/spiffs", 7) == 0);
|
||
if (is_spiffs) {
|
||
ensure_spiffs_audio();
|
||
if (!s_spiffs_mounted) {
|
||
ESP_LOGW(TAG, "SPIFFS not mounted — beep fallback");
|
||
audio_play_tone(880.0f, 200);
|
||
return;
|
||
}
|
||
} else {
|
||
ensure_sd_mounted();
|
||
if (!s_sd_mounted) {
|
||
ESP_LOGW(TAG, "SD not mounted — beep fallback");
|
||
audio_play_tone(880.0f, 200);
|
||
return;
|
||
}
|
||
}
|
||
|
||
char full[192];
|
||
if (path[0] == '/') {
|
||
snprintf(full, sizeof(full), "%s", path);
|
||
} else {
|
||
snprintf(full, sizeof(full), "%s/%s", PLIP_SD_MOUNT, path);
|
||
}
|
||
|
||
FILE *f = fopen(full, "rb");
|
||
if (!f) {
|
||
ESP_LOGW(TAG, "file not found: %s", full);
|
||
audio_play_tone(880.0f, 200);
|
||
return;
|
||
}
|
||
|
||
/* Read enough of the file to parse the WAV header (up to 512 bytes). */
|
||
uint8_t hdr_buf[512];
|
||
size_t hdr_read = fread(hdr_buf, 1, sizeof(hdr_buf), f);
|
||
if (hdr_read < WAV_MIN_HEADER) {
|
||
ESP_LOGW(TAG, "WAV too short: %zu bytes", hdr_read);
|
||
fclose(f);
|
||
audio_play_tone(880.0f, 200);
|
||
return;
|
||
}
|
||
|
||
wav_info_t wi = {0};
|
||
if (parse_wav_header(hdr_buf, hdr_read, &wi) != ESP_OK) {
|
||
ESP_LOGW(TAG, "WAV parse failed: %s", full);
|
||
fclose(f);
|
||
audio_play_tone(880.0f, 200);
|
||
return;
|
||
}
|
||
if (wi.bits_per_sample != 16) {
|
||
ESP_LOGW(TAG, "WAV: %d-bit not supported (need 16-bit)", wi.bits_per_sample);
|
||
fclose(f);
|
||
return;
|
||
}
|
||
|
||
ESP_LOGI(TAG, "WAV: %"PRIu32" Hz %d-bit %d ch, %"PRIu32" bytes PCM",
|
||
wi.sample_rate, wi.bits_per_sample, wi.channels, wi.data_size);
|
||
|
||
/* Seek to the PCM data section. */
|
||
if (fseek(f, (long)wi.data_offset, SEEK_SET) != 0) {
|
||
ESP_LOGW(TAG, "fseek to data failed");
|
||
fclose(f);
|
||
return;
|
||
}
|
||
|
||
/* Stream PCM to I2S in small chunks — no large malloc needed. */
|
||
static uint8_t s_play_chunk[PLAY_CHUNK_BYTES]; /* static: avoids stack pressure */
|
||
uint32_t remaining = wi.data_size;
|
||
size_t total_written = 0;
|
||
|
||
while (!s_stop_req && remaining > 0) {
|
||
uint32_t to_read = (remaining < PLAY_CHUNK_BYTES) ? remaining : PLAY_CHUNK_BYTES;
|
||
size_t n = fread(s_play_chunk, 1, to_read, f);
|
||
if (n == 0) break;
|
||
size_t i2s_written = 0;
|
||
esp_err_t ret = i2s_channel_write(s_spk_handle, s_play_chunk, n,
|
||
&i2s_written, pdMS_TO_TICKS(500));
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
|
||
break;
|
||
}
|
||
total_written += i2s_written;
|
||
remaining -= (uint32_t)n;
|
||
}
|
||
|
||
fclose(f);
|
||
float dur = (float)total_written / (float)(wi.sample_rate * wi.channels * (wi.bits_per_sample / 8));
|
||
ESP_LOGI(TAG, "play done: %zu bytes written, %.2fs", total_written, dur);
|
||
}
|
||
|
||
/* Generate a simple embedded 3-tone cue C5-E5-G5 as proof-of-life. */
|
||
static void play_embedded_cue(void)
|
||
{
|
||
/* Three 200ms tones: C5 523Hz, E5 659Hz, G5 784Hz */
|
||
const float freqs[] = {523.0f, 659.0f, 784.0f};
|
||
for (int i = 0; i < 3 && !s_stop_req; i++) {
|
||
audio_play_tone(freqs[i], 200);
|
||
vTaskDelay(pdMS_TO_TICKS(50));
|
||
}
|
||
}
|
||
|
||
/* ── Ring tone task ──────────────────────────────────────────────────────── */
|
||
|
||
static void ring_task(void *arg)
|
||
{
|
||
(void)arg;
|
||
/* French ring: 1.5s ON at ~440Hz, 3.5s OFF, repeat until stop */
|
||
while (!s_stop_req) {
|
||
/* ON: 1.5s at 440 Hz */
|
||
const int on_ms = 1500;
|
||
const int total_samples = SAMPLE_RATE * on_ms / 1000;
|
||
const float freq = 440.0f;
|
||
const float amp = 12000.0f;
|
||
#define RING_CHUNK 128
|
||
int16_t buf[RING_CHUNK * 2]; /* stereo buffer */
|
||
int sample_idx = 0;
|
||
while (!s_stop_req && sample_idx < total_samples) {
|
||
int chunk = (total_samples - sample_idx < RING_CHUNK)
|
||
? (total_samples - sample_idx) : RING_CHUNK;
|
||
for (int i = 0; i < chunk; i++) {
|
||
float t = (float)(sample_idx + i) / (float)SAMPLE_RATE;
|
||
int16_t v = (int16_t)(amp * sinf(2.0f * (float)M_PI * freq * t));
|
||
buf[i * 2] = v; /* L */
|
||
buf[i * 2 + 1] = v; /* R */
|
||
}
|
||
size_t written = 0;
|
||
i2s_channel_write(s_spk_handle, buf, (size_t)chunk * 2 * sizeof(int16_t),
|
||
&written, pdMS_TO_TICKS(500));
|
||
sample_idx += chunk;
|
||
}
|
||
#undef RING_CHUNK
|
||
if (s_stop_req) break;
|
||
/* OFF: 3.5s silence */
|
||
const int off_ms = 3500;
|
||
const int steps = off_ms / 50;
|
||
for (int i = 0; i < steps && !s_stop_req; i++) {
|
||
vTaskDelay(pdMS_TO_TICKS(50));
|
||
}
|
||
}
|
||
vTaskDelete(NULL);
|
||
}
|
||
|
||
/* ── Worker task ─────────────────────────────────────────────────────────── */
|
||
|
||
static void audio_worker_task(void *arg)
|
||
{
|
||
(void)arg;
|
||
ESP_LOGI(TAG, "audio worker ready");
|
||
audio_cmd_t cmd;
|
||
for (;;) {
|
||
if (xQueueReceive(s_queue, &cmd, portMAX_DELAY) != pdTRUE) continue;
|
||
s_stop_req = false;
|
||
|
||
switch (cmd.kind) {
|
||
case CMD_STOP:
|
||
s_stop_req = true;
|
||
ESP_LOGI(TAG, "stop");
|
||
break;
|
||
case CMD_RING:
|
||
/* Ring must sound on-hook (caller hears it before picking up).
|
||
* Force PA on for the duration of the ring regardless of hook state. */
|
||
ESP_LOGI(TAG, "ring start — forcing PA on");
|
||
audio_pa_set(true);
|
||
xTaskCreate(ring_task, "ring", 4096, NULL, 4, NULL);
|
||
break;
|
||
case CMD_PLAY: {
|
||
/* Gate playback on hook state: no sound if handset is on-hook. */
|
||
if (!phone_is_offhook()) {
|
||
ESP_LOGI(TAG, "play ignored: on-hook (handset down)");
|
||
break;
|
||
}
|
||
const char *p = cmd.path;
|
||
if (!p || !*p || strncmp(p, "embedded:", 9) == 0) {
|
||
ESP_LOGI(TAG, "play: embedded cue");
|
||
play_embedded_cue();
|
||
} else {
|
||
ESP_LOGI(TAG, "play: %s", p);
|
||
play_wav_file(p);
|
||
}
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── Public API ──────────────────────────────────────────────────────────── */
|
||
|
||
void audio_pa_set(bool enable)
|
||
{
|
||
gpio_set_level(PLIP_PA_ENABLE, enable ? 1 : 0);
|
||
ESP_LOGI(TAG, "PA %s", enable ? "ON" : "OFF");
|
||
}
|
||
|
||
esp_err_t audio_init(void)
|
||
{
|
||
/* 1. ES8388 I2C init + register sequence. */
|
||
esp_err_t ret = es8388_init();
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGE(TAG, "es8388_init failed: %s", esp_err_to_name(ret));
|
||
return ret;
|
||
}
|
||
|
||
/* 2. Create I2S channels (TX = speaker, RX = mic — full-duplex pair). */
|
||
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(PLIP_I2S_NUM,
|
||
I2S_ROLE_MASTER);
|
||
chan_cfg.auto_clear = true;
|
||
ret = i2s_new_channel(&chan_cfg, &s_spk_handle, &s_mic_handle);
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGE(TAG, "i2s_new_channel: %s", esp_err_to_name(ret));
|
||
return ret;
|
||
}
|
||
|
||
/* 3a+3b. Full-duplex I2S config: SAME gpio_cfg for TX and RX.
|
||
* IDF5 i2s_std constitutes full-duplex ONLY when TX and RX configs are
|
||
* identical (memcmp). If they differ, on ESP32 HW v1 it tries to move
|
||
* RX to I2S_NUM_1, breaking clock routing and yielding permanent zeros.
|
||
* Fix: set both dout=PLIP_I2S_DOUT and din=PLIP_I2S_DIN in the same
|
||
* config and apply it to both handles. The IDF GPIO driver handles
|
||
* direction internally (dout→output, din→input). */
|
||
i2s_std_config_t std_cfg = {
|
||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE),
|
||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
|
||
I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
|
||
.gpio_cfg = {
|
||
.mclk = PLIP_I2S_MCLK,
|
||
.bclk = PLIP_I2S_BCLK,
|
||
.ws = PLIP_I2S_WS,
|
||
.dout = PLIP_I2S_DOUT,
|
||
.din = PLIP_I2S_DIN,
|
||
.invert_flags = {
|
||
.mclk_inv = false,
|
||
.bclk_inv = false,
|
||
.ws_inv = false,
|
||
},
|
||
},
|
||
};
|
||
ret = i2s_channel_init_std_mode(s_spk_handle, &std_cfg);
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGE(TAG, "i2s_channel_init_std_mode (TX): %s", esp_err_to_name(ret));
|
||
return ret;
|
||
}
|
||
ret = i2s_channel_init_std_mode(s_mic_handle, &std_cfg);
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGE(TAG, "i2s_channel_init_std_mode (RX): %s", esp_err_to_name(ret));
|
||
return ret;
|
||
}
|
||
|
||
/* Enable both TX and RX at boot for full-duplex operation.
|
||
* Both must be enabled for the I2S hardware to route ASDOUT→DIN correctly.
|
||
* RX data will be read only during capture; TX stays active for clock/DAC. */
|
||
ret = i2s_channel_enable(s_spk_handle);
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGE(TAG, "i2s_channel_enable (TX): %s", esp_err_to_name(ret));
|
||
return ret;
|
||
}
|
||
ret = i2s_channel_enable(s_mic_handle);
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGE(TAG, "i2s_channel_enable (RX): %s", esp_err_to_name(ret));
|
||
/* Non-fatal: TX still works for playback; capture will fail gracefully. */
|
||
ESP_LOGW(TAG, "RX enable failed — capture disabled, playback unaffected");
|
||
s_mic_handle = NULL;
|
||
}
|
||
|
||
/* 3c. Quick RX sanity: read multiple frames to check DMA and signal quality.
|
||
* Do this before the 440Hz boot tone to see baseline ADC output. */
|
||
if (s_mic_handle) {
|
||
int16_t test_buf[320 * 2];
|
||
size_t test_read = 0;
|
||
/* Drain any startup garbage (first few frames may be codec settling). */
|
||
for (int flush = 0; flush < 5; flush++) {
|
||
i2s_channel_read(s_mic_handle, test_buf, sizeof(test_buf), &test_read, pdMS_TO_TICKS(50));
|
||
}
|
||
/* Read a real frame and report. */
|
||
esp_err_t rx_ret = i2s_channel_read(s_mic_handle, test_buf,
|
||
sizeof(test_buf), &test_read,
|
||
pdMS_TO_TICKS(200));
|
||
int32_t max_val = 0, rms_sq = 0;
|
||
int n = (int)(test_read / 2);
|
||
for (int i = 0; i < n; i++) {
|
||
int32_t v = test_buf[i] < 0 ? -test_buf[i] : test_buf[i];
|
||
if (v > max_val) max_val = v;
|
||
rms_sq += (int32_t)test_buf[i] * test_buf[i];
|
||
}
|
||
rms_sq /= (n > 0 ? n : 1);
|
||
ESP_LOGI(TAG, "RX sanity: ret=%s read=%zu max=%"PRId32" rms²=%"PRId32" s[0..3]=(%d,%d,%d,%d)",
|
||
esp_err_to_name(rx_ret), test_read, max_val, rms_sq,
|
||
n>0?test_buf[0]:0, n>0?test_buf[1]:0, n>1?test_buf[2]:0, n>1?test_buf[3]:0);
|
||
}
|
||
|
||
/* 4. Create async command queue + worker. */
|
||
s_queue = xQueueCreate(8, sizeof(audio_cmd_t));
|
||
if (!s_queue) return ESP_ERR_NO_MEM;
|
||
|
||
BaseType_t ok = xTaskCreatePinnedToCore(audio_worker_task, "audio_work",
|
||
8192, NULL, 5, NULL, 0);
|
||
if (ok != pdPASS) return ESP_ERR_NO_MEM;
|
||
|
||
ESP_LOGI(TAG, "audio init OK (I2S TX ready, RX handle allocated, ES8388 live)");
|
||
return ESP_OK;
|
||
}
|
||
|
||
/* ── Mic capture ─────────────────────────────────────────────────────────── */
|
||
|
||
/*
|
||
* audio_capture_wav() — capture microphone audio and return a WAV buffer.
|
||
*
|
||
* Strategy: half-duplex. Disable TX, enable RX, read until VAD silence timeout
|
||
* or hard cap, then disable RX, re-enable TX.
|
||
*
|
||
* ES8388 delivers stereo I2S frames (L+R int16) even though the mic is mono.
|
||
* We average L+R into a single mono sample for the output WAV.
|
||
*
|
||
* Returns number of bytes written to out (header + PCM), or -1 on error.
|
||
*/
|
||
int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms)
|
||
{
|
||
if (!s_mic_handle || !s_spk_handle) {
|
||
ESP_LOGE(TAG, "capture: I2S handles not ready");
|
||
return -1;
|
||
}
|
||
if (!out || out_max < 44 + 320) {
|
||
ESP_LOGE(TAG, "capture: output buffer too small");
|
||
return -1;
|
||
}
|
||
|
||
/* WAV parameters for output: 16 kHz, mono, S16-LE. */
|
||
const uint32_t cap_rate = SAMPLE_RATE; /* 16000 */
|
||
const uint16_t cap_ch = 1; /* mono output */
|
||
const uint16_t cap_bits = 16;
|
||
const size_t hdr_bytes = 44;
|
||
|
||
/* Per-frame: 20 ms at 16kHz = 320 mono samples = 640 bytes PCM out.
|
||
* ES8388 stereo frame = 640 bytes input (320 samples × 2 ch × 2 bytes). */
|
||
const int frame_samples = 320; /* mono samples per frame */
|
||
const size_t frame_in_bytes = (size_t)frame_samples * 2 * sizeof(int16_t); /* stereo */
|
||
const size_t frame_out_bytes = (size_t)frame_samples * sizeof(int16_t); /* mono */
|
||
|
||
/* Allocate scratch buffer in SPIRAM if available, else internal heap. */
|
||
int16_t *rx_buf = (int16_t *)heap_caps_malloc(frame_in_bytes,
|
||
MALLOC_CAP_SPIRAM);
|
||
if (!rx_buf) {
|
||
rx_buf = (int16_t *)malloc(frame_in_bytes);
|
||
}
|
||
if (!rx_buf) {
|
||
ESP_LOGE(TAG, "capture: OOM for rx_buf");
|
||
return -1;
|
||
}
|
||
|
||
/* Enable RX while keeping TX running (TX drives MCLK/BCLK/WS for the codec). */
|
||
esp_err_t ret = i2s_channel_enable(s_mic_handle);
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGE(TAG, "capture: i2s_channel_enable(RX): %s", esp_err_to_name(ret));
|
||
free(rx_buf);
|
||
return -1;
|
||
}
|
||
|
||
/* Reserve space for WAV header at start of out buffer. */
|
||
uint8_t *pcm_out = out + hdr_bytes;
|
||
size_t pcm_max = out_max - hdr_bytes;
|
||
size_t pcm_written = 0;
|
||
|
||
const int max_frames = (max_ms / 20);
|
||
const int silence_frames = (silence_ms / 20);
|
||
|
||
/* VAD thresholds (raw S16: ~1% FS = ~328 for speech onset, ~0.3% for end) */
|
||
const int32_t vad_onset_rms_sq = 328 * 328; /* ~1% FS² */
|
||
const int32_t vad_silence_rms_sq = 100 * 100; /* ~0.3% FS² */
|
||
|
||
bool voice_started = false;
|
||
int silent_frames = 0;
|
||
int total_frames = 0;
|
||
|
||
ESP_LOGI(TAG, "capture: RX enabled, max_ms=%d silence_ms=%d", max_ms, silence_ms);
|
||
|
||
for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
|
||
size_t bytes_read = 0;
|
||
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
|
||
&bytes_read, pdMS_TO_TICKS(100));
|
||
if (ret != ESP_OK || bytes_read == 0) {
|
||
ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
|
||
continue;
|
||
}
|
||
|
||
/* Downmix stereo → mono, compute RMS². */
|
||
int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
|
||
int64_t rms_sq = 0;
|
||
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
|
||
for (int i = 0; i < n_stereo; i++) {
|
||
int32_t l = rx_buf[i * 2];
|
||
int32_t r = rx_buf[i * 2 + 1];
|
||
int16_t mono = (int16_t)((l + r) / 2);
|
||
out_frame[i] = mono;
|
||
rms_sq += (int64_t)mono * mono;
|
||
}
|
||
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
|
||
|
||
/* VAD logic. */
|
||
if (!voice_started) {
|
||
if (rms_sq >= vad_onset_rms_sq) {
|
||
voice_started = true;
|
||
silent_frames = 0;
|
||
ESP_LOGI(TAG, "capture: voice onset at frame %d (rms²=%"PRId64")", f, rms_sq);
|
||
}
|
||
/* Before voice onset: still accumulate (to avoid clipping onset).
|
||
* But don't count towards silence timeout yet. */
|
||
} else {
|
||
if (rms_sq < vad_silence_rms_sq) {
|
||
silent_frames++;
|
||
if (silent_frames >= silence_frames) {
|
||
total_frames = f + 1;
|
||
ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)", silent_frames, f);
|
||
pcm_written += (size_t)n_stereo * sizeof(int16_t);
|
||
break;
|
||
}
|
||
} else {
|
||
silent_frames = 0;
|
||
}
|
||
}
|
||
|
||
pcm_written += (size_t)n_stereo * sizeof(int16_t);
|
||
total_frames = f + 1;
|
||
}
|
||
|
||
/* Disable RX; TX was never stopped. */
|
||
i2s_channel_disable(s_mic_handle);
|
||
free(rx_buf);
|
||
|
||
if (pcm_written == 0) {
|
||
ESP_LOGW(TAG, "capture: no audio captured (silence or timeout)");
|
||
/* Return a valid but minimal WAV with 0 PCM bytes so caller can inspect. */
|
||
}
|
||
|
||
uint32_t data_size = (uint32_t)pcm_written;
|
||
uint32_t byte_rate = cap_rate * cap_ch * (cap_bits / 8);
|
||
uint16_t block_align = (uint16_t)(cap_ch * (cap_bits / 8));
|
||
uint32_t riff_size = 36 + data_size;
|
||
|
||
/* Write WAV header (44 bytes, little-endian). */
|
||
memcpy(out + 0, "RIFF", 4);
|
||
memcpy(out + 4, &riff_size, 4);
|
||
memcpy(out + 8, "WAVE", 4);
|
||
memcpy(out + 12, "fmt ", 4);
|
||
uint32_t fmt_size = 16;
|
||
uint16_t fmt_pcm = 1;
|
||
memcpy(out + 16, &fmt_size, 4);
|
||
memcpy(out + 20, &fmt_pcm, 2);
|
||
memcpy(out + 22, &cap_ch, 2);
|
||
memcpy(out + 24, &cap_rate, 4);
|
||
memcpy(out + 28, &byte_rate, 4);
|
||
memcpy(out + 32, &block_align, 2);
|
||
memcpy(out + 34, &cap_bits, 2);
|
||
memcpy(out + 36, "data", 4);
|
||
memcpy(out + 40, &data_size, 4);
|
||
|
||
int total_bytes = (int)(hdr_bytes + pcm_written);
|
||
float duration_s = (float)pcm_written / (float)(cap_rate * (cap_bits / 8) * cap_ch);
|
||
ESP_LOGI(TAG, "capture done: %d frames, %.2fs, %zu PCM bytes, %d total bytes",
|
||
total_frames, duration_s, pcm_written, total_bytes);
|
||
return total_bytes;
|
||
}
|
||
|
||
/* ── Streaming capture API ───────────────────────────────────────────────── */
|
||
|
||
/* Scratch buffer reused across frames (lives for the duration of a capture). */
|
||
static int16_t *s_rx_scratch = NULL; /* stereo frame: 320*2 int16_t = 1280 bytes */
|
||
static int s_frame_debug_count = 0;
|
||
#define CAP_FRAME_SAMPLES 320 /* 20 ms @ 16kHz */
|
||
#define CAP_FRAME_IN_BYTES (CAP_FRAME_SAMPLES * 2 * sizeof(int16_t))
|
||
|
||
int audio_capture_begin(int max_ms, int silence_ms)
|
||
{
|
||
(void)max_ms; (void)silence_ms; /* caller drives VAD/timeout */
|
||
if (!s_mic_handle) {
|
||
ESP_LOGE(TAG, "capture_begin: RX handle not available");
|
||
return -1;
|
||
}
|
||
s_rx_scratch = (int16_t *)heap_caps_malloc(CAP_FRAME_IN_BYTES, MALLOC_CAP_SPIRAM);
|
||
if (!s_rx_scratch) s_rx_scratch = (int16_t *)malloc(CAP_FRAME_IN_BYTES);
|
||
if (!s_rx_scratch) {
|
||
ESP_LOGE(TAG, "capture_begin: OOM for scratch buf");
|
||
return -1;
|
||
}
|
||
s_frame_debug_count = 0;
|
||
|
||
/* Full-duplex: keep TX running (provides MCLK/BCLK/WS clocks to codec).
|
||
* RX is already enabled at boot. */
|
||
ESP_LOGI(TAG, "capture_begin: ready (full-duplex, TX running)");
|
||
return 0;
|
||
}
|
||
|
||
|
||
|
||
/*
|
||
* Read one 20 ms frame from the mic, downmix stereo→mono.
|
||
* mono_out must hold n_samples (320) int16_t values.
|
||
* Sets *rms_sq_out to mean-squared energy of the frame.
|
||
* Returns number of mono samples written, 0 on read timeout, -1 on error.
|
||
*/
|
||
int audio_capture_read_frame(int16_t *mono_out, int n_samples, int64_t *rms_sq_out)
|
||
{
|
||
if (!s_rx_scratch) return -1;
|
||
size_t bytes_read = 0;
|
||
esp_err_t ret = i2s_channel_read(s_mic_handle, s_rx_scratch,
|
||
CAP_FRAME_IN_BYTES, &bytes_read,
|
||
pdMS_TO_TICKS(100));
|
||
if (ret != ESP_OK || bytes_read == 0) return 0;
|
||
|
||
int n = (int)(bytes_read / (2 * sizeof(int16_t)));
|
||
if (n > n_samples) n = n_samples;
|
||
|
||
/* Debug: log first 3 frames raw stereo values to diagnose zero data. */
|
||
if (s_frame_debug_count < 3) {
|
||
int16_t r0 = (n > 0) ? s_rx_scratch[0] : 0;
|
||
int16_t r1 = (n > 0) ? s_rx_scratch[1] : 0;
|
||
int16_t r2 = (n > 1) ? s_rx_scratch[2] : 0;
|
||
int16_t r3 = (n > 1) ? s_rx_scratch[3] : 0;
|
||
ESP_LOGI(TAG, "RX frame %d: bytes_read=%zu n=%d raw[0..3]=(%d,%d,%d,%d)",
|
||
s_frame_debug_count, bytes_read, n, r0, r1, r2, r3);
|
||
s_frame_debug_count++;
|
||
}
|
||
|
||
int64_t rms_sq = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
int32_t l = s_rx_scratch[i * 2];
|
||
int32_t r = s_rx_scratch[i * 2 + 1];
|
||
int16_t mono = (int16_t)((l + r) / 2);
|
||
mono_out[i] = mono;
|
||
rms_sq += (int64_t)mono * mono;
|
||
}
|
||
if (rms_sq_out) *rms_sq_out = (n > 0) ? rms_sq / n : 0;
|
||
return n;
|
||
}
|
||
|
||
void audio_capture_end(void)
|
||
{
|
||
if (s_rx_scratch) { free(s_rx_scratch); s_rx_scratch = NULL; }
|
||
ESP_LOGI(TAG, "capture_end: done");
|
||
}
|
||
|
||
i2s_chan_handle_t audio_spk_handle(void)
|
||
{
|
||
return s_spk_handle;
|
||
}
|
||
|
||
void audio_play_tone(float frequency_hz, int duration_ms)
|
||
{
|
||
if (!s_spk_handle || duration_ms <= 0) return;
|
||
const int total_samples = SAMPLE_RATE * duration_ms / 1000;
|
||
const float amp = 12000.0f;
|
||
/* Stereo: each sample = L + R = 2 int16_t values. */
|
||
/* Process 128 mono samples per iteration = 256 stereo int16_t = 512 bytes. */
|
||
#define TONE_CHUNK 128
|
||
int16_t buf[TONE_CHUNK * 2]; /* stereo buffer: 2 samples per frame */
|
||
size_t written = 0;
|
||
int idx = 0;
|
||
while (idx < total_samples) {
|
||
int chunk = (total_samples - idx < TONE_CHUNK)
|
||
? (total_samples - idx) : TONE_CHUNK;
|
||
for (int i = 0; i < chunk; i++) {
|
||
float t = (float)(idx + i) / (float)SAMPLE_RATE;
|
||
int16_t v = (int16_t)(amp * sinf(2.0f * (float)M_PI * frequency_hz * t));
|
||
buf[i * 2] = v; /* L */
|
||
buf[i * 2 + 1] = v; /* R */
|
||
}
|
||
i2s_channel_write(s_spk_handle, buf, (size_t)chunk * 2 * sizeof(int16_t),
|
||
&written, pdMS_TO_TICKS(500));
|
||
idx += chunk;
|
||
}
|
||
#undef TONE_CHUNK
|
||
}
|
||
|
||
esp_err_t audio_play_async(const char *path)
|
||
{
|
||
if (!s_queue) return ESP_ERR_INVALID_STATE;
|
||
audio_cmd_t cmd = { .kind = CMD_PLAY };
|
||
if (path && *path) {
|
||
strncpy(cmd.path, path, sizeof(cmd.path) - 1);
|
||
}
|
||
if (xQueueSend(s_queue, &cmd, 0) != pdTRUE) {
|
||
ESP_LOGW(TAG, "play queue full");
|
||
return ESP_FAIL;
|
||
}
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t audio_stop(void)
|
||
{
|
||
s_stop_req = true;
|
||
if (!s_queue) return ESP_ERR_INVALID_STATE;
|
||
audio_cmd_t cmd = { .kind = CMD_STOP };
|
||
xQueueSend(s_queue, &cmd, 0);
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t audio_ring_start(void)
|
||
{
|
||
s_stop_req = false;
|
||
if (!s_queue) return ESP_ERR_INVALID_STATE;
|
||
audio_cmd_t cmd = { .kind = CMD_RING };
|
||
if (xQueueSend(s_queue, &cmd, 0) != pdTRUE) {
|
||
return ESP_FAIL;
|
||
}
|
||
return ESP_OK;
|
||
}
|