feat(idf): TTS playback I2S TX + mute gate
voice_pipeline slice 9: I2S_NUM_1 TX channel for TTS playback, voice_pipeline_play_start/chunk/end APIs (clock reconfig 16k mic vs 24k F5), mute-during-TTS gate in capture_task (state==SPEAKING skips AFE feed to prevent echo retriggering wake). enable_tts_playback opt-in default false. Build green: 1.45 MB app, 8% free.
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
// * wake event routes through a user callback and auto-transitions
|
||||
// the pipeline into VOICE_STATE_LISTENING.
|
||||
//
|
||||
// Slice 7 deliverable (this revision):
|
||||
// Slice 7 deliverable:
|
||||
// * managed dependency on `espressif/esp_websocket_client` (~1.4)
|
||||
// * after a wake event, post-AFE PCM chunks (16 kHz mono int16) are
|
||||
// streamed as binary WebSocket frames to the MacStudio voice-bridge
|
||||
@@ -30,6 +30,18 @@
|
||||
// `voice_pipeline_set_stt_callback`. The hint/intent dispatch
|
||||
// itself stays in npc_engine (out of scope for this slice).
|
||||
//
|
||||
// Slice 9 deliverable (this revision):
|
||||
// * TTS playback from the voice-bridge: PCM 16-bit mono @ 24 kHz
|
||||
// received as binary WebSocket frames between
|
||||
// `{"type":"speak_start", ...}` and `{"type":"speak_end", ...}`.
|
||||
// * I2S TX bring-up on I2S_NUM_1 (MAX98357A DAC: BCLK / LRC / DIN).
|
||||
// Disabled by default — opt-in via `enable_tts_playback`.
|
||||
// * mute-during-TTS gate: while `state == VOICE_STATE_SPEAKING`, the
|
||||
// capture task keeps draining I2S input but does NOT feed AFE,
|
||||
// guaranteeing no wake re-trigger from the speaker echo.
|
||||
// * state machine: LISTENING → SPEAKING on `speak_start`,
|
||||
// SPEAKING → IDLE on `speak_end`.
|
||||
//
|
||||
// The HTTP plumbing to the hints engine still lives in the separate
|
||||
// hints_client component to avoid a circular dependency with npc_engine.
|
||||
|
||||
@@ -85,6 +97,16 @@ typedef struct {
|
||||
// the string must outlive `voice_pipeline_init` (typically a static
|
||||
// `#define` in main.c).
|
||||
const char *voice_bridge_ws_url;
|
||||
|
||||
// Slice 9: TTS playback configuration. The voice-bridge streams
|
||||
// back PCM 16-bit mono at 24 kHz between `speak_start` and
|
||||
// `speak_end`. We render it on I2S_NUM_1 driving a MAX98357A class-D
|
||||
// DAC (3-pin I2S: BCLK / LRC / DIN). Opt-in to keep slice-7-only
|
||||
// builds bit-identical.
|
||||
bool enable_tts_playback;
|
||||
int i2s_out_bclk_pin; // GPIO11 default (Freenove convention)
|
||||
int i2s_out_lrc_pin; // GPIO12 default (LRCK / WS)
|
||||
int i2s_out_din_pin; // GPIO13 default (DIN to DAC)
|
||||
} voice_pipeline_config_t;
|
||||
|
||||
// Reasonable defaults for a Freenove ESP32-S3 + INMP441 wiring. Override per
|
||||
@@ -134,6 +156,28 @@ esp_err_t voice_pipeline_stop_streaming(void);
|
||||
// pipeline detected end-of-speech (or stop_streaming was called).
|
||||
bool voice_pipeline_is_streaming(void);
|
||||
|
||||
// Slice 9 — TTS playback API. Called from the WS layer when the
|
||||
// voice-bridge announces / streams / closes a `speak_*` exchange.
|
||||
//
|
||||
// `voice_pipeline_play_start` reconfigures the I2S TX clock if the
|
||||
// requested `sample_rate` differs from the current one and enables the
|
||||
// channel. Transitions the state machine to VOICE_STATE_SPEAKING which
|
||||
// activates the mute gate (mic input keeps draining I2S but is NOT
|
||||
// fed to AFE).
|
||||
//
|
||||
// `voice_pipeline_play_chunk` writes PCM bytes (16-bit mono LE) to
|
||||
// the DAC. `len` is in bytes. Blocks up to 100 ms in the I2S DMA
|
||||
// queue.
|
||||
//
|
||||
// `voice_pipeline_play_end` disables the TX channel and returns the
|
||||
// state machine to VOICE_STATE_IDLE so the next wake can fire.
|
||||
//
|
||||
// All three are no-ops (return ESP_ERR_INVALID_STATE) if
|
||||
// `enable_tts_playback` was false at init.
|
||||
esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format);
|
||||
esp_err_t voice_pipeline_play_chunk(const uint8_t *buf, size_t len);
|
||||
esp_err_t voice_pipeline_play_end(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -51,6 +51,11 @@ static struct {
|
||||
bool ready;
|
||||
voice_pipeline_config_t cfg;
|
||||
i2s_chan_handle_t rx_chan;
|
||||
// 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
|
||||
voice_state_t state;
|
||||
TaskHandle_t capture_task;
|
||||
bool capture_run;
|
||||
@@ -90,6 +95,13 @@ void voice_pipeline_default_config(voice_pipeline_config_t *out) {
|
||||
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) {
|
||||
@@ -205,6 +217,48 @@ static esp_err_t i2s_setup(void) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// 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;
|
||||
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) {
|
||||
@@ -332,6 +386,13 @@ static void capture_task(void *pv) {
|
||||
}
|
||||
if (bytes_read == 0) continue;
|
||||
|
||||
// Slice 9: mute-during-TTS gate. While the pipeline is in
|
||||
// SPEAKING state we keep draining I2S so the DMA buffers
|
||||
// don't overflow, but we do NOT feed AFE — this prevents
|
||||
// the speaker output (which leaks back into the mic) from
|
||||
// re-triggering the wake word during a TTS reply.
|
||||
if (s_pipe.state == VOICE_STATE_SPEAKING) continue;
|
||||
|
||||
s_pipe.afe_iface->feed(s_pipe.afe_data, feed_buf);
|
||||
|
||||
// Drain anything available without blocking the feed cadence.
|
||||
@@ -447,6 +508,20 @@ esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -525,3 +600,64 @@ 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;
|
||||
}
|
||||
// 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));
|
||||
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) 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");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user