fcb872c226
CI / platformio (pull_request) Failing after 10m4s
- voice_pipeline : mutex play_lock pris dans play_start / rendu dans play_end
(+ tous chemins d'erreur). Sérialise TTS et musique sur le canal I2S TX
partagé ; un play_start concurrent attend 2 s puis renvoie BUSY.
- media_manager : décodage MP3 via helix (chmorgan/esp-libhelix-mp3). Refactor
playback_task en dispatcher d'extension → stream_wav / stream_mp3. stream_mp3
décode frame par frame (buffers heap), downmix stéréo→mono, volume, feed I2S.
Stack tâche 5120→8192 (le décodeur consomme de la pile).
- sdkconfig.defaults : CONFIG_FATFS_LFN_HEAP — les assets SD ont des noms > 8.3
(SCENE_WIN.mp3, sonar_hint.mp3…) ; sans LFN fopen échoue ("open failed").
Validé sur Freenove : upload SCENE_WIN.mp3 (510 Ko, stéréo) sur /sdcard →
play → helix décode 22050 Hz 2ch → downmix → I2S MAX98357A. win.mp3 22050 mono
idem. Mutex : TTS et musique ne peuvent plus se chevaucher sur le DAC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
647 lines
26 KiB
C
647 lines
26 KiB
C
// voice_pipeline — ESP-IDF implementation. See voice_pipeline.h for the
|
||
// scope of slices 5, 6 and 7. The AFE / WakeNet integration is gated on
|
||
// `cfg.enable_wake_word`; if init fails (PSRAM exhausted, model
|
||
// partition absent, etc.) we log + degrade silently to the slice-5
|
||
// I2S-only capture path so the rest of the firmware still boots.
|
||
//
|
||
// Slice (task 4): I2S RX ownership transferred to mic_broker. The
|
||
// voice_pipeline registers on_npc_frame() for MIC_NPC_LISTEN and the broker
|
||
// delivers PCM16 mono 320-sample frames; voice_pipeline accumulates them
|
||
// into the AFE feed buffer (or counts heartbeat bytes in stub mode).
|
||
// The I2S TX path (speaker / MAX98357A on I2S_NUM_1) is unchanged.
|
||
|
||
#include "voice_pipeline.h"
|
||
#include "voice_pipeline_ws.h"
|
||
|
||
#include <string.h>
|
||
|
||
#include "driver/i2s_std.h"
|
||
#include "esp_heap_caps.h"
|
||
#include "esp_log.h"
|
||
#include "esp_mac.h"
|
||
#include "freertos/FreeRTOS.h"
|
||
#include "freertos/task.h"
|
||
#include "freertos/semphr.h"
|
||
|
||
#include "esp_afe_config.h"
|
||
#include "esp_afe_sr_iface.h"
|
||
#include "esp_afe_sr_models.h"
|
||
#include "esp_wn_iface.h"
|
||
#include "esp_wn_models.h"
|
||
#include "model_path.h"
|
||
|
||
#include "mic_broker.h"
|
||
|
||
static const char *TAG = "voice_pipeline";
|
||
|
||
// Slice 6 placeholder wake word. Standard Espressif WakeNet9 model
|
||
// shipped under permissive license — no commercial agreement required.
|
||
// Custom "Professeur Zacus" model lands later (see voice spec P4).
|
||
#define VOICE_DEFAULT_WAKE_WORD_NAME "wn9_hiesp"
|
||
|
||
// Partition holding srmodels.bin (added in partitions.csv as a 1 MB
|
||
// SPIFFS region). Must match the partition `Name` column.
|
||
#define VOICE_SR_MODEL_PARTITION "model"
|
||
|
||
#define CAPTURE_TASK_STACK 8192
|
||
#define CAPTURE_TASK_PRIO 5
|
||
#define CAPTURE_CHUNK_BYTES 1024 // fallback (slice-5 path) — 16-bit @16 kHz = 32 ms slice
|
||
|
||
// Slice 7: end-of-utterance detection. AFE feed chunk size at 16 kHz /
|
||
// AFE_MODE_LOW_COST is typically 512 samples = 32 ms. 50 consecutive
|
||
// silence chunks ≈ 1.5 s of silence, which matches the spec's
|
||
// "sustained ~1.5 s" end-of-speech criterion. A safety cap stops a
|
||
// runaway stream after ~10 s of audio so a stuck VAD never blocks the
|
||
// pipeline forever.
|
||
#define END_SILENCE_CHUNKS 50
|
||
#define STREAMING_MAX_CHUNKS (16000 * 10 / 512) // ~10 s
|
||
|
||
static struct {
|
||
bool ready;
|
||
voice_pipeline_config_t cfg;
|
||
// rx_chan removed: I2S RX is now owned by mic_broker (task 4).
|
||
// Slice 9: I2S TX channel for TTS playback (MAX98357A DAC). NULL
|
||
// if `enable_tts_playback == false` or alloc/init failed.
|
||
i2s_chan_handle_t tx_chan;
|
||
bool tx_enabled; // channel currently enabled
|
||
uint32_t tx_sample_rate; // current configured rate
|
||
SemaphoreHandle_t play_lock; // serialises TTS vs media playback (one session at a time)
|
||
voice_state_t state;
|
||
// capture_task / capture_run removed: mic_broker's task delivers frames
|
||
// via on_npc_frame(); broker mode (MIC_NPC_LISTEN/MIC_IDLE) gates it.
|
||
|
||
// Wake-word callback (set independently of init, may be NULL).
|
||
voice_wake_callback_t wake_cb;
|
||
void *wake_cb_ctx;
|
||
|
||
// ESP-SR handles. NULL if wake-word not enabled or alloc failed —
|
||
// capture_task uses the dumb I2S read path in that case.
|
||
srmodel_list_t *sr_models;
|
||
const esp_afe_sr_iface_t *afe_iface;
|
||
esp_afe_sr_data_t *afe_data;
|
||
char wake_word_name[32];
|
||
int afe_feed_chunk_samples; // per-channel
|
||
int afe_feed_channel_num; // mic + ref
|
||
int afe_fetch_chunk_samples; // post-AFE, what we stream
|
||
|
||
// Slice 7: streaming state. `stream_active` mirrors voice_ws_is_streaming
|
||
// but is owned by the on_npc_frame callback so we don't race with the WS
|
||
// event loop on transitions. `silence_chunks` counts sustained
|
||
// AFE_VAD_SILENCE fetches; reaching END_SILENCE_CHUNKS closes the upload.
|
||
bool stream_active;
|
||
uint32_t silence_chunks;
|
||
uint32_t streamed_chunks;
|
||
char session_id[32];
|
||
|
||
// AFE feed accumulator: broker delivers 320-sample chunks; the AFE may
|
||
// require a larger feed_chunk_samples. We accumulate here before feeding.
|
||
int16_t *afe_accum; // PSRAM alloc; size = feed_bytes
|
||
size_t afe_accum_fill; // samples already buffered
|
||
} s_pipe = {
|
||
.state = VOICE_STATE_IDLE,
|
||
};
|
||
|
||
void voice_pipeline_default_config(voice_pipeline_config_t *out) {
|
||
if (!out) return;
|
||
out->i2s_bclk_pin = 14; // GPIO14 -> SCK
|
||
out->i2s_ws_pin = 15; // GPIO15 -> WS
|
||
out->i2s_din_pin = 22; // GPIO22 -> SD (INMP441)
|
||
out->sample_rate_hz = 16000;
|
||
out->auto_start_capture = false;
|
||
out->enable_wake_word = false;
|
||
out->voice_bridge_ws_url = NULL;
|
||
// Slice 9: TTS playback defaults — disabled. Pinout follows the
|
||
// suggested Freenove ESP32-S3 convention: BCLK=11, LRC=12, DIN=13.
|
||
// Confirm at flash time before driving the DAC.
|
||
out->enable_tts_playback = false;
|
||
out->i2s_out_bclk_pin = 11;
|
||
out->i2s_out_lrc_pin = 12;
|
||
out->i2s_out_din_pin = 13;
|
||
}
|
||
|
||
bool voice_pipeline_wake_word_active(void) {
|
||
return (s_pipe.afe_iface != NULL && s_pipe.afe_data != NULL);
|
||
}
|
||
|
||
esp_err_t voice_pipeline_set_wake_callback(voice_wake_callback_t cb,
|
||
void *user_ctx) {
|
||
s_pipe.wake_cb = cb;
|
||
s_pipe.wake_cb_ctx = user_ctx;
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t voice_pipeline_set_stt_callback(voice_stt_callback_t cb,
|
||
void *user_ctx) {
|
||
voice_ws_set_stt_callback(cb, user_ctx);
|
||
return ESP_OK;
|
||
}
|
||
|
||
bool voice_pipeline_is_streaming(void) {
|
||
return s_pipe.stream_active;
|
||
}
|
||
|
||
static void session_id_init(void) {
|
||
uint8_t mac[6] = {0};
|
||
if (esp_efuse_mac_get_default(mac) == ESP_OK) {
|
||
snprintf(s_pipe.session_id, sizeof(s_pipe.session_id),
|
||
"%02x%02x%02x%02x%02x%02x",
|
||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||
} else {
|
||
strncpy(s_pipe.session_id, "unknown",
|
||
sizeof(s_pipe.session_id) - 1);
|
||
s_pipe.session_id[sizeof(s_pipe.session_id) - 1] = '\0';
|
||
}
|
||
}
|
||
|
||
// Open the WebSocket lazily on the first wake (or on
|
||
// voice_pipeline_start_streaming). Returns ESP_OK on success or if the
|
||
// stream is already open.
|
||
static esp_err_t streaming_begin(void) {
|
||
if (s_pipe.stream_active) return ESP_OK;
|
||
if (!voice_ws_is_configured()) return ESP_ERR_INVALID_STATE;
|
||
|
||
esp_err_t err = voice_ws_open_streaming();
|
||
if (err != ESP_OK) {
|
||
ESP_LOGW(TAG, "voice_ws_open_streaming failed: %s",
|
||
esp_err_to_name(err));
|
||
return err;
|
||
}
|
||
s_pipe.stream_active = true;
|
||
s_pipe.silence_chunks = 0;
|
||
s_pipe.streamed_chunks = 0;
|
||
ESP_LOGI(TAG, "streaming started");
|
||
return ESP_OK;
|
||
}
|
||
|
||
static void streaming_end(const char *reason) {
|
||
if (!s_pipe.stream_active) return;
|
||
ESP_LOGI(TAG, "streaming end (%s) — sent %u chunks, silence=%u",
|
||
reason ? reason : "?",
|
||
(unsigned) s_pipe.streamed_chunks,
|
||
(unsigned) s_pipe.silence_chunks);
|
||
voice_ws_close_streaming();
|
||
s_pipe.stream_active = false;
|
||
s_pipe.silence_chunks = 0;
|
||
s_pipe.streamed_chunks = 0;
|
||
voice_pipeline_set_state(VOICE_STATE_IDLE);
|
||
}
|
||
|
||
esp_err_t voice_pipeline_start_streaming(void) {
|
||
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
|
||
if (!voice_ws_is_configured()) {
|
||
ESP_LOGW(TAG, "start_streaming: no voice_bridge_ws_url configured");
|
||
return ESP_ERR_INVALID_STATE;
|
||
}
|
||
voice_pipeline_set_state(VOICE_STATE_LISTENING);
|
||
return streaming_begin();
|
||
}
|
||
|
||
esp_err_t voice_pipeline_stop_streaming(void) {
|
||
streaming_end("manual_stop");
|
||
return ESP_OK;
|
||
}
|
||
|
||
// i2s_setup() removed: I2S RX is now owned by mic_broker (task 4).
|
||
// mic_broker_init() is called from voice_pipeline_init() instead.
|
||
|
||
// Slice 9: bring up the I2S TX channel for TTS playback on a separate
|
||
// I2S port (I2S_NUM_1) so the mic capture on I2S_NUM_0 keeps running
|
||
// untouched. Configures Philips std mode, mono, 16-bit, at the
|
||
// pipeline's default sample rate (typically 16 kHz). The actual TTS
|
||
// rate may differ (Piper f5_tts ≈ 24 kHz) — voice_pipeline_play_start
|
||
// reconfigures the clock on the fly when needed.
|
||
static esp_err_t i2s_tx_setup(void) {
|
||
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
|
||
esp_err_t err = i2s_new_channel(&chan_cfg, &s_pipe.tx_chan, NULL);
|
||
if (err != ESP_OK) return err;
|
||
|
||
// Initial clock = same as mic; will be reconfigured at play_start
|
||
// if the bridge announces a different rate.
|
||
s_pipe.tx_sample_rate = s_pipe.cfg.sample_rate_hz;
|
||
|
||
i2s_std_config_t std_cfg = {
|
||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(s_pipe.tx_sample_rate),
|
||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
|
||
I2S_SLOT_MODE_MONO),
|
||
.gpio_cfg = {
|
||
.mclk = I2S_GPIO_UNUSED,
|
||
.bclk = s_pipe.cfg.i2s_out_bclk_pin,
|
||
.ws = s_pipe.cfg.i2s_out_lrc_pin,
|
||
.dout = s_pipe.cfg.i2s_out_din_pin,
|
||
.din = I2S_GPIO_UNUSED,
|
||
.invert_flags = {
|
||
.mclk_inv = false,
|
||
.bclk_inv = false,
|
||
.ws_inv = false,
|
||
},
|
||
},
|
||
};
|
||
err = i2s_channel_init_std_mode(s_pipe.tx_chan, &std_cfg);
|
||
if (err != ESP_OK) {
|
||
i2s_del_channel(s_pipe.tx_chan);
|
||
s_pipe.tx_chan = NULL;
|
||
return err;
|
||
}
|
||
s_pipe.tx_enabled = false;
|
||
if (s_pipe.play_lock == NULL) {
|
||
s_pipe.play_lock = xSemaphoreCreateMutex();
|
||
}
|
||
return ESP_OK;
|
||
}
|
||
|
||
// Bring up esp-sr AFE + WakeNet. Returns ESP_OK on success. On failure
|
||
// the caller logs and falls back to the dumb I2S capture path.
|
||
static esp_err_t wake_word_setup(void) {
|
||
s_pipe.sr_models = esp_srmodel_init(VOICE_SR_MODEL_PARTITION);
|
||
if (!s_pipe.sr_models || s_pipe.sr_models->num <= 0) {
|
||
ESP_LOGW(TAG, "esp_srmodel_init('%s') returned no models — "
|
||
"wake word disabled (check srmodels.bin flashed)",
|
||
VOICE_SR_MODEL_PARTITION);
|
||
return ESP_ERR_NOT_FOUND;
|
||
}
|
||
ESP_LOGI(TAG, "esp-sr loaded %d model(s) from partition '%s'",
|
||
s_pipe.sr_models->num, VOICE_SR_MODEL_PARTITION);
|
||
for (int i = 0; i < s_pipe.sr_models->num; i++) {
|
||
ESP_LOGI(TAG, " model[%d] = %s", i, s_pipe.sr_models->model_name[i]);
|
||
}
|
||
|
||
char *wn_name = esp_srmodel_filter(s_pipe.sr_models, ESP_WN_PREFIX, NULL);
|
||
if (!wn_name) {
|
||
ESP_LOGW(TAG, "no WakeNet model found in partition — wake disabled");
|
||
return ESP_ERR_NOT_FOUND;
|
||
}
|
||
strncpy(s_pipe.wake_word_name, wn_name, sizeof(s_pipe.wake_word_name) - 1);
|
||
s_pipe.wake_word_name[sizeof(s_pipe.wake_word_name) - 1] = '\0';
|
||
ESP_LOGI(TAG, "selected wake model = %s (placeholder, slice 6)",
|
||
s_pipe.wake_word_name);
|
||
|
||
// Single mic, no reference channel: input format "M" (one micropohne).
|
||
afe_config_t *afe_cfg = afe_config_init("M", s_pipe.sr_models,
|
||
AFE_TYPE_SR, AFE_MODE_LOW_COST);
|
||
if (!afe_cfg) {
|
||
ESP_LOGW(TAG, "afe_config_init returned NULL");
|
||
return ESP_FAIL;
|
||
}
|
||
// Force the wake model we just discovered, in case the default
|
||
// selection logic picks something we don't want.
|
||
afe_cfg->wakenet_init = true;
|
||
afe_cfg->wakenet_model_name = s_pipe.wake_word_name;
|
||
// 1-mic SR: AEC needs a reference channel we don't have, SE (BSS)
|
||
// needs >= 2 mics. Disable both, keep NS + VAD + AGC.
|
||
afe_cfg->aec_init = false;
|
||
afe_cfg->se_init = false;
|
||
afe_cfg->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
|
||
|
||
s_pipe.afe_iface = esp_afe_handle_from_config(afe_cfg);
|
||
if (!s_pipe.afe_iface) {
|
||
ESP_LOGW(TAG, "esp_afe_handle_from_config returned NULL");
|
||
afe_config_free(afe_cfg);
|
||
return ESP_FAIL;
|
||
}
|
||
s_pipe.afe_data = s_pipe.afe_iface->create_from_config(afe_cfg);
|
||
afe_config_free(afe_cfg);
|
||
if (!s_pipe.afe_data) {
|
||
ESP_LOGW(TAG, "AFE create_from_config failed (likely OOM in PSRAM)");
|
||
s_pipe.afe_iface = NULL;
|
||
return ESP_ERR_NO_MEM;
|
||
}
|
||
|
||
s_pipe.afe_feed_chunk_samples = s_pipe.afe_iface->get_feed_chunksize(s_pipe.afe_data);
|
||
s_pipe.afe_feed_channel_num = s_pipe.afe_iface->get_feed_channel_num(s_pipe.afe_data);
|
||
s_pipe.afe_fetch_chunk_samples = s_pipe.afe_iface->get_fetch_chunksize(s_pipe.afe_data);
|
||
|
||
ESP_LOGI(TAG, "AFE up: feed_chunk=%d samples × %d ch, fetch_chunk=%d, sample_rate=%d Hz",
|
||
s_pipe.afe_feed_chunk_samples,
|
||
s_pipe.afe_feed_channel_num,
|
||
s_pipe.afe_fetch_chunk_samples,
|
||
s_pipe.afe_iface->get_samp_rate(s_pipe.afe_data));
|
||
if (s_pipe.afe_iface->print_pipeline) {
|
||
s_pipe.afe_iface->print_pipeline(s_pipe.afe_data);
|
||
}
|
||
return ESP_OK;
|
||
}
|
||
|
||
static void wake_word_teardown(void) {
|
||
if (s_pipe.afe_data && s_pipe.afe_iface && s_pipe.afe_iface->destroy) {
|
||
s_pipe.afe_iface->destroy(s_pipe.afe_data);
|
||
}
|
||
s_pipe.afe_data = NULL;
|
||
s_pipe.afe_iface = NULL;
|
||
if (s_pipe.sr_models) {
|
||
esp_srmodel_deinit(s_pipe.sr_models);
|
||
s_pipe.sr_models = NULL;
|
||
}
|
||
}
|
||
|
||
// NPC mic callback — invoked by mic_broker's capture task for every
|
||
// MB_FRAME-sample (320 samples = 20 ms) frame while MIC_NPC_LISTEN is active.
|
||
//
|
||
// Two paths:
|
||
// * AFE active : accumulate frames into s_pipe.afe_accum until we have a
|
||
// full afe_feed_chunk_samples worth, then feed+fetch+react.
|
||
// * Stub (no esp-sr): count bytes for heartbeat logging.
|
||
//
|
||
// Called from mic_broker's task context (stack 4096); must not block long.
|
||
static void on_npc_frame(const int16_t *pcm, size_t samples, void *ctx) {
|
||
(void)ctx;
|
||
|
||
if (voice_pipeline_wake_word_active()) {
|
||
// ── AFE path ─────────────────────────────────────────────────────
|
||
const int need = s_pipe.afe_feed_chunk_samples * s_pipe.afe_feed_channel_num;
|
||
if (!s_pipe.afe_accum) return; // alloc failed at init; silently skip
|
||
|
||
// Slice 9: mute-during-TTS gate — drain the mic but do not feed AFE.
|
||
if (s_pipe.state == VOICE_STATE_SPEAKING) {
|
||
s_pipe.afe_accum_fill = 0; // also reset accumulator on mute
|
||
return;
|
||
}
|
||
|
||
size_t copied = 0;
|
||
while (copied < samples) {
|
||
size_t space = (size_t)need - s_pipe.afe_accum_fill;
|
||
size_t take = samples - copied;
|
||
if (take > space) take = space;
|
||
memcpy(s_pipe.afe_accum + s_pipe.afe_accum_fill, pcm + copied,
|
||
take * sizeof(int16_t));
|
||
s_pipe.afe_accum_fill += take;
|
||
copied += take;
|
||
|
||
if ((int)s_pipe.afe_accum_fill < need) break; // not full yet
|
||
|
||
// Buffer full — feed AFE.
|
||
// INMP441 single-mic: afe_feed_channel_num == 1, so accum IS
|
||
// the feed buffer directly (no interleaving needed).
|
||
s_pipe.afe_iface->feed(s_pipe.afe_data, s_pipe.afe_accum);
|
||
s_pipe.afe_accum_fill = 0;
|
||
|
||
// Fetch result (non-blocking).
|
||
static uint32_t s_feeds = 0;
|
||
afe_fetch_result_t *res = s_pipe.afe_iface->fetch_with_delay(
|
||
s_pipe.afe_data, 0);
|
||
if (res && res->ret_value == ESP_OK) {
|
||
if (res->wakeup_state == WAKENET_DETECTED) {
|
||
const char *word = s_pipe.wake_word_name;
|
||
ESP_LOGI(TAG, "WAKE detected: word=%s vol=%.1fdB chan=%d",
|
||
word, res->data_volume, res->trigger_channel_id);
|
||
voice_pipeline_set_state(VOICE_STATE_LISTENING);
|
||
if (s_pipe.wake_cb) {
|
||
s_pipe.wake_cb(word, s_pipe.wake_cb_ctx);
|
||
}
|
||
// Slice 7: open WS stream immediately on wake so
|
||
// the player's first words are captured.
|
||
if (voice_ws_is_configured() && !s_pipe.stream_active) {
|
||
if (streaming_begin() != ESP_OK) {
|
||
ESP_LOGW(TAG, "streaming_begin failed at wake — "
|
||
"returning to IDLE");
|
||
voice_pipeline_set_state(VOICE_STATE_IDLE);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Slice 7: push post-AFE PCM and watch VAD.
|
||
if (s_pipe.stream_active && res->data && res->data_size > 0) {
|
||
esp_err_t serr = voice_ws_send_chunk(
|
||
res->data, res->data_size / sizeof(int16_t));
|
||
if (serr != ESP_OK) {
|
||
ESP_LOGW(TAG, "send_chunk err %s — closing stream",
|
||
esp_err_to_name(serr));
|
||
streaming_end("send_error");
|
||
} else {
|
||
s_pipe.streamed_chunks++;
|
||
// res->vad_state: VAD_SILENCE=0, VAD_SPEECH=1
|
||
if (res->vad_state == VAD_SILENCE) {
|
||
s_pipe.silence_chunks++;
|
||
} else {
|
||
s_pipe.silence_chunks = 0;
|
||
}
|
||
if (s_pipe.silence_chunks >= END_SILENCE_CHUNKS) {
|
||
streaming_end("vad_silence");
|
||
} else if (s_pipe.streamed_chunks >= STREAMING_MAX_CHUNKS) {
|
||
streaming_end("max_duration");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (++s_feeds % 100 == 0) {
|
||
ESP_LOGD(TAG, "AFE feed heartbeat: %u chunks", (unsigned)s_feeds);
|
||
}
|
||
}
|
||
} else {
|
||
// ── Stub path (no esp-sr) ─────────────────────────────────────────
|
||
static uint32_t s_total = 0;
|
||
static uint32_t s_ticks = 0;
|
||
s_total += (uint32_t)(samples * sizeof(int16_t));
|
||
if (++s_ticks % 80 == 0) { // ~80 × 20 ms ≈ 1.6 s
|
||
ESP_LOGI(TAG, "capture heartbeat: %u bytes total", (unsigned)s_total);
|
||
}
|
||
}
|
||
}
|
||
|
||
esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) {
|
||
if (s_pipe.ready) return ESP_OK;
|
||
voice_pipeline_config_t cfg;
|
||
if (config) {
|
||
cfg = *config;
|
||
} else {
|
||
voice_pipeline_default_config(&cfg);
|
||
}
|
||
s_pipe.cfg = cfg;
|
||
|
||
session_id_init();
|
||
|
||
// Bring up mic_broker (single I2S RX owner).
|
||
// ESP_ERR_INVALID_STATE means the broker is already running (another
|
||
// caller initialised it first) — treat as success.
|
||
esp_err_t err = mic_broker_init(cfg.i2s_bclk_pin, cfg.i2s_ws_pin,
|
||
cfg.i2s_din_pin, 16000);
|
||
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
||
ESP_LOGW(TAG, "mic_broker_init failed: %s — staying idle without capture",
|
||
esp_err_to_name(err));
|
||
// Don't fail init: state machine stays usable.
|
||
s_pipe.ready = true;
|
||
s_pipe.state = VOICE_STATE_IDLE;
|
||
return ESP_OK;
|
||
}
|
||
mic_broker_register(MIC_NPC_LISTEN, on_npc_frame, &s_pipe);
|
||
|
||
// Slice 9: optional I2S TX bring-up for TTS playback. Failure is
|
||
// non-fatal — the rest of the voice loop still works.
|
||
if (cfg.enable_tts_playback) {
|
||
esp_err_t te = i2s_tx_setup();
|
||
if (te != ESP_OK) {
|
||
ESP_LOGW(TAG, "i2s_tx_setup failed: %s — TTS playback disabled",
|
||
esp_err_to_name(te));
|
||
} else {
|
||
ESP_LOGI(TAG, "I2S TX ready (BCLK=%d LRC=%d DIN=%d) — TTS enabled",
|
||
cfg.i2s_out_bclk_pin, cfg.i2s_out_lrc_pin,
|
||
cfg.i2s_out_din_pin);
|
||
}
|
||
}
|
||
|
||
if (cfg.enable_wake_word) {
|
||
esp_err_t we = wake_word_setup();
|
||
if (we != ESP_OK) {
|
||
ESP_LOGW(TAG, "wake_word_setup failed: %s — degrading to stub capture",
|
||
esp_err_to_name(we));
|
||
// Don't fail init — the rest of the firmware should still
|
||
// come up. The callback will run in slice-5 stub mode.
|
||
wake_word_teardown();
|
||
} else {
|
||
// Broker delivers linear mono; AFE must be configured for 1 channel.
|
||
if (s_pipe.afe_feed_channel_num != 1) {
|
||
ESP_LOGE(TAG, "AFE feed_channel_num=%d but broker delivers mono (1) — "
|
||
"configuration mismatch", s_pipe.afe_feed_channel_num);
|
||
wake_word_teardown();
|
||
return ESP_ERR_NOT_SUPPORTED;
|
||
}
|
||
// Allocate the AFE accumulator now that we know chunk sizes.
|
||
size_t feed_bytes = (size_t)s_pipe.afe_feed_chunk_samples
|
||
* s_pipe.afe_feed_channel_num * sizeof(int16_t);
|
||
s_pipe.afe_accum = heap_caps_malloc(feed_bytes,
|
||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||
if (!s_pipe.afe_accum) {
|
||
ESP_LOGW(TAG, "PSRAM alloc for AFE accum failed (%u B) — "
|
||
"degrading to stub capture", (unsigned)feed_bytes);
|
||
wake_word_teardown();
|
||
} else {
|
||
s_pipe.afe_accum_fill = 0;
|
||
ESP_LOGI(TAG, "AFE accum alloc OK (%u B)", (unsigned)feed_bytes);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Slice 7: configure the WS layer if the caller provided a URL.
|
||
// The actual connection is opened lazily on the first wake (or
|
||
// on voice_pipeline_start_streaming).
|
||
if (cfg.voice_bridge_ws_url && *cfg.voice_bridge_ws_url) {
|
||
esp_err_t wsc = voice_ws_configure(cfg.voice_bridge_ws_url,
|
||
s_pipe.session_id,
|
||
cfg.sample_rate_hz);
|
||
if (wsc != ESP_OK) {
|
||
ESP_LOGW(TAG, "voice_ws_configure failed: %s — streaming disabled",
|
||
esp_err_to_name(wsc));
|
||
} else {
|
||
ESP_LOGI(TAG, "voice-bridge streaming wired: %s",
|
||
cfg.voice_bridge_ws_url);
|
||
}
|
||
}
|
||
|
||
s_pipe.ready = true;
|
||
s_pipe.state = VOICE_STATE_IDLE;
|
||
ESP_LOGI(TAG, "ready (BCLK=%d WS=%d DIN=%d @%u Hz, wake=%s, stream=%s)",
|
||
cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin,
|
||
(unsigned) cfg.sample_rate_hz,
|
||
voice_pipeline_wake_word_active() ? s_pipe.wake_word_name : "off",
|
||
voice_ws_is_configured() ? "on" : "off");
|
||
|
||
if (cfg.auto_start_capture) {
|
||
return voice_pipeline_start_capture();
|
||
}
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t voice_pipeline_start_capture(void) {
|
||
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
|
||
// Reset stale partial accumulator from a previous stop before new session.
|
||
s_pipe.afe_accum_fill = 0;
|
||
// Route mic frames to the NPC callback via the broker.
|
||
mic_broker_set_mode(MIC_NPC_LISTEN);
|
||
voice_pipeline_set_state(VOICE_STATE_LISTENING);
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t voice_pipeline_stop_capture(void) {
|
||
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
|
||
// Stop delivery to the NPC callback.
|
||
mic_broker_set_mode(MIC_IDLE);
|
||
// Close any in-flight stream so we don't leak a WS connection.
|
||
if (s_pipe.stream_active) {
|
||
streaming_end("capture_stop");
|
||
}
|
||
voice_pipeline_set_state(VOICE_STATE_IDLE);
|
||
return ESP_OK;
|
||
}
|
||
|
||
voice_state_t voice_pipeline_get_state(void) {
|
||
return s_pipe.state;
|
||
}
|
||
|
||
esp_err_t voice_pipeline_set_state(voice_state_t state) {
|
||
s_pipe.state = state;
|
||
return ESP_OK;
|
||
}
|
||
|
||
// ── Slice 9: TTS playback over I2S TX (I2S_NUM_1) ────────────────────────────
|
||
|
||
esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format) {
|
||
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
|
||
if (!s_pipe.tx_chan) {
|
||
ESP_LOGW(TAG, "play_start: no TX channel (enable_tts_playback=false?)");
|
||
return ESP_ERR_INVALID_STATE;
|
||
}
|
||
// Serialise the shared TX channel: only one playback session (TTS or
|
||
// media_manager) may own the DAC at a time. Held until play_end().
|
||
if (s_pipe.play_lock &&
|
||
xSemaphoreTake(s_pipe.play_lock, pdMS_TO_TICKS(2000)) != pdTRUE) {
|
||
ESP_LOGW(TAG, "play_start: busy — another playback owns the I2S TX");
|
||
return ESP_ERR_TIMEOUT;
|
||
}
|
||
// Reconfigure the I2S clock if the bridge announces a different rate.
|
||
// F5-TTS produces 24 kHz; the mic side runs at 16 kHz by default.
|
||
if (sample_rate != 0 && sample_rate != s_pipe.tx_sample_rate) {
|
||
if (s_pipe.tx_enabled) {
|
||
i2s_channel_disable(s_pipe.tx_chan);
|
||
s_pipe.tx_enabled = false;
|
||
}
|
||
i2s_std_clk_config_t clk = I2S_STD_CLK_DEFAULT_CONFIG(sample_rate);
|
||
esp_err_t err = i2s_channel_reconfig_std_clock(s_pipe.tx_chan, &clk);
|
||
if (err != ESP_OK) {
|
||
ESP_LOGW(TAG, "play_start: clk reconfig %u Hz failed: %s",
|
||
(unsigned) sample_rate, esp_err_to_name(err));
|
||
if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
|
||
return err;
|
||
}
|
||
s_pipe.tx_sample_rate = sample_rate;
|
||
}
|
||
if (!s_pipe.tx_enabled) {
|
||
esp_err_t err = i2s_channel_enable(s_pipe.tx_chan);
|
||
if (err != ESP_OK) {
|
||
if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
|
||
return err;
|
||
}
|
||
s_pipe.tx_enabled = true;
|
||
}
|
||
voice_pipeline_set_state(VOICE_STATE_SPEAKING);
|
||
ESP_LOGI(TAG, "play_start: sr=%u format=%s",
|
||
(unsigned) sample_rate, format ? format : "(null)");
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t voice_pipeline_play_chunk(const uint8_t *buf, size_t len) {
|
||
if (!s_pipe.ready || !s_pipe.tx_chan || !s_pipe.tx_enabled) {
|
||
return ESP_ERR_INVALID_STATE;
|
||
}
|
||
if (!buf || len == 0) return ESP_ERR_INVALID_ARG;
|
||
size_t written = 0;
|
||
esp_err_t err = i2s_channel_write(s_pipe.tx_chan, buf, len, &written,
|
||
pdMS_TO_TICKS(100));
|
||
if (err != ESP_OK) {
|
||
ESP_LOGW(TAG, "play_chunk: i2s write err=%s wrote=%u/%u",
|
||
esp_err_to_name(err), (unsigned) written, (unsigned) len);
|
||
}
|
||
return err;
|
||
}
|
||
|
||
esp_err_t voice_pipeline_play_end(void) {
|
||
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
|
||
if (s_pipe.tx_chan && s_pipe.tx_enabled) {
|
||
i2s_channel_disable(s_pipe.tx_chan);
|
||
s_pipe.tx_enabled = false;
|
||
}
|
||
voice_pipeline_set_state(VOICE_STATE_IDLE);
|
||
ESP_LOGI(TAG, "play_end");
|
||
if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
|
||
return ESP_OK;
|
||
}
|