Files
ESP32_ZACUS/idf_zacus/components/voice_pipeline/voice_pipeline.c
T
clement fcb872c226
CI / platformio (pull_request) Failing after 10m4s
feat(p4): décodeur MP3 (helix) + mutex I2S TTS↔musique + FATFS LFN
- 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>
2026-06-13 16:42:13 +02:00

647 lines
26 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.
// 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;
}