Files
ESP32_ZACUS/plip_voice/main/audio.c
T
clement aa7ae277ed
CI / platformio (pull_request) Failing after 4m0s
CI / platformio (push) Failing after 14m58s
feat(plip): voice loop + DTMF + ring cadence
- hook polarity active-HIGH + auto-resync (was LOW)
- ring cadence FT 1.5s ON / 3.5s OFF
- DTMF Goertzel decoder (dtmf.c/h) + rotary debounce
- LISTEN half-duplex: capture → /v1/voice/reply → play
- WAV playback buffered PSRAM + mono→stereo upmix
- SPIFFS mount at boot for pre-loaded greetings
- ES8388: DAC digital vol + mic PGA + GPIO INPUT_OUTPUT
- turn_client multipart + 90s timeout + fixed routing
- debug endpoints: vol/dacvol/offhook/getfile/hookmon
2026-06-15 21:12:33 +02:00

835 lines
32 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 volatile bool s_playing = false; /* true while the worker plays a clip */
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 = true, /* format a blank/corrupt partition at boot */
};
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;
}
/* 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;
}
/* The I2S TX slot is STEREO @ SAMPLE_RATE. A mono WAV must be expanded to
* L+R or it is consumed at 2x rate (the "chipmunk" fast/high-pitch bug).
* A WAV whose rate differs from SAMPLE_RATE would also play at the wrong
* speed — warn (the gateway TTS always returns 16 kHz, matching). */
if (wi.sample_rate != SAMPLE_RATE) {
ESP_LOGW(TAG, "WAV rate %"PRIu32" Hz != I2S %d Hz — playback speed will be off",
wi.sample_rate, SAMPLE_RATE);
}
const bool mono = (wi.channels == 1);
/* Load the ENTIRE PCM into PSRAM BEFORE playing. Streaming fread() from
* SPIFFS *between* I2S writes stalls the DMA → underrun → audible
* distortion ("saturation"). Diagnostic confirmed: the stored WAV is clean
* (0% clip) and RAM-generated tones play clean, but SPIFFS-streamed WAVs
* distorted. Reading it all up-front = zero file I/O during playback. */
uint8_t *pcm = heap_caps_malloc(wi.data_size, MALLOC_CAP_SPIRAM);
if (!pcm) pcm = malloc(wi.data_size);
if (!pcm) {
ESP_LOGE(TAG, "play: OOM for %"PRIu32"-byte PCM buffer", wi.data_size);
fclose(f);
return;
}
size_t pcm_len = fread(pcm, 1, wi.data_size, f);
fclose(f);
static int16_t s_stereo_chunk[PLAY_CHUNK_BYTES]; /* mono→stereo scratch */
size_t off = 0;
size_t total_written = 0;
const size_t step = mono ? (PLAY_CHUNK_BYTES / 2) : PLAY_CHUNK_BYTES;
while (!s_stop_req && off < pcm_len) {
size_t bytes = (pcm_len - off < step) ? (pcm_len - off) : step;
const uint8_t *out = pcm + off;
size_t out_len = bytes;
if (mono) {
/* Duplicate each 16-bit mono sample into L and R. */
size_t samples = bytes / 2;
const int16_t *src = (const int16_t *)(pcm + off);
for (size_t k = 0; k < samples; k++) {
s_stereo_chunk[2 * k] = src[k];
s_stereo_chunk[2 * k + 1] = src[k];
}
out = (const uint8_t *)s_stereo_chunk;
out_len = samples * 4; /* 2 channels × 2 bytes */
}
size_t w = 0;
esp_err_t ret = i2s_channel_write(s_spk_handle, out, out_len, &w, pdMS_TO_TICKS(500));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
break;
}
total_written += w;
off += bytes;
}
free(pcm);
float dur = (float)total_written / (float)(SAMPLE_RATE * 2 * 2); /* 16k stereo 16-bit */
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;
}
s_playing = true;
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);
}
s_playing = false;
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");
}
bool audio_is_playing(void)
{
return s_playing;
}
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;
}
/* Apply the configured output volume. es8388_init() leaves OUT2 at 0 dB
* (max) — too loud for a handset earpiece — so set it explicitly here. */
es8388_set_volume(CONFIG_PLIP_SPEAKER_VOLUME);
/* 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;
/* Mount SPIFFS at init (not lazily) so turn_client can WRITE the NPC WAV to
* /spiffs before any playback has triggered the lazy mount. */
ensure_spiffs_audio();
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;
}
/* RX is already enabled at boot (full-duplex). Calling enable again returns
* ESP_ERR_INVALID_STATE — that's fine, it just means RX is already running.
* Only a genuinely different error is fatal. */
esp_err_t ret = i2s_channel_enable(s_mic_handle);
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
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;
}
/* Leave RX enabled (full-duplex, as at boot). Disabling it here would make
* the next capture's enable a no-op INVALID_STATE AND break other RX users
* (streaming capture / DTMF) that assume RX stays running. */
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;
}