[i2s_audio] Add full duplex support (#19959)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Remco van Essen
2026-10-15 21:35:46 -10:00
committed by GitHub
co-authored by Claude Opus 5.5 J. Nick Koston J. Nick Koston
parent 6555da2906
commit 51b1fdcc8b
17 changed files with 695 additions and 79 deletions
+85 -3
View File
@@ -19,8 +19,16 @@ from esphome.components.esp32.const import (
VARIANT_ESP32S3,
)
from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_CHANNEL,
CONF_ID,
CONF_PLATFORM,
CONF_SAMPLE_RATE,
CONF_SPEAKER,
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj, MockObjClass
import esphome.final_validate as fv
@@ -42,6 +50,9 @@ CONF_I2S_BCLK_PIN = "i2s_bclk_pin"
CONF_I2S_LRCLK_PIN = "i2s_lrclk_pin"
CONF_I2S_AUDIO_ID = "i2s_audio_id"
CONF_FULL_DUPLEX = "full_duplex"
CONF_I2S_COMM_FMT = "i2s_comm_fmt"
CONF_SPDIF_MODE = "spdif_mode"
CONF_I2S_MODE = "i2s_mode"
CONF_PRIMARY = "primary"
@@ -185,7 +196,8 @@ def i2s_audio_component_schema(
async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None:
await cg.register_parented(var, config[CONF_I2S_AUDIO_ID])
parent = await cg.get_variable(config[CONF_I2S_AUDIO_ID])
await cg.register_parented(var, parent)
cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]]))
slot_mode = config[CONF_CHANNEL]
if slot_mode != CONF_STEREO:
@@ -200,6 +212,12 @@ async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None
cg.add(var.set_use_apll(config[CONF_USE_APLL]))
cg.add(var.set_mclk_multiple(I2S_MCLK_MULTIPLE[config[CONF_MCLK_MULTIPLE]]))
if str(config[CONF_I2S_AUDIO_ID]) in _get_data().full_duplex_buses:
if config[CONF_ID].type.inherits_from(I2SAudioIn):
cg.add(parent.set_audio_in(var))
else:
cg.add(parent.set_audio_out(var))
CONFIG_SCHEMA = cv.Schema(
{
@@ -207,6 +225,7 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_FULL_DUPLEX, default=False): cv.boolean,
},
)
@@ -216,6 +235,7 @@ class I2SAudioData:
"""I2S audio component state stored in CORE.data."""
port_map: dict[str, int] = field(default_factory=dict)
full_duplex_buses: set[str] = field(default_factory=set)
def _get_data() -> I2SAudioData:
@@ -262,8 +282,56 @@ def _assign_ports() -> None:
next_port += 1
def _bus_devices(full_config: ConfigType, domain: str, bus_id: str) -> list[ConfigType]:
return [
device
for device in full_config.get(domain, [])
if device.get(CONF_PLATFORM) == DOMAIN
and str(device[CONF_I2S_AUDIO_ID]) == bus_id
]
def _validate_full_duplex(full_config: ConfigType, bus_id: str) -> None:
"""Check that a full duplex bus has one microphone and speakers that can share its clocks and TX channel."""
microphones = _bus_devices(full_config, MICROPHONE_DOMAIN, bus_id)
speakers = _bus_devices(full_config, CONF_SPEAKER, bus_id)
if len(microphones) != 1 or not speakers:
raise cv.Invalid(
f"'{CONF_FULL_DUPLEX}' requires exactly one i2s_audio microphone and at least one "
f"i2s_audio speaker on bus '{bus_id}'"
)
microphone = microphones[0]
if microphone.get(CONF_PDM):
raise cv.Invalid(f"A PDM microphone cannot use a '{CONF_FULL_DUPLEX}' bus")
for speaker in speakers:
if speaker.get(CONF_SPDIF_MODE):
raise cv.Invalid(f"An SPDIF speaker cannot use a '{CONF_FULL_DUPLEX}' bus")
# Both directions run from the same bit and word clocks
for key in (
CONF_SAMPLE_RATE,
CONF_BITS_PER_SAMPLE,
CONF_I2S_MODE,
CONF_USE_APLL,
CONF_MCLK_MULTIPLE,
):
if microphone[key] != speaker[key]:
raise cv.Invalid(
f"The microphone and speaker '{speaker[CONF_ID]}' on '{CONF_FULL_DUPLEX}' bus "
f"'{bus_id}' must use the same '{key}'"
)
# The TX channel is set up once from one speaker's configuration, and the speakers take turns using it
first = speakers[0]
for speaker in speakers[1:]:
for key in (CONF_I2S_DOUT_PIN, CONF_CHANNEL, CONF_I2S_COMM_FMT):
if speaker.get(key) != first.get(key):
raise cv.Invalid(
f"The speakers on '{CONF_FULL_DUPLEX}' bus '{bus_id}' must use the same '{key}'"
)
def _final_validate(_: ConfigType) -> None:
i2s_audio_configs = fv.full_config.get()[DOMAIN]
full_config = fv.full_config.get()
i2s_audio_configs = full_config[DOMAIN]
variant = get_esp32_variant()
if variant not in I2S_PORTS:
raise cv.Invalid(f"Unsupported variant {variant}")
@@ -273,6 +341,13 @@ def _final_validate(_: ConfigType) -> None:
)
_assign_ports()
data = _get_data()
for config in i2s_audio_configs:
if config[CONF_FULL_DUPLEX]:
bus_id = str(config[CONF_ID])
_validate_full_duplex(full_config, bus_id)
data.full_duplex_buses.add(bus_id)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -299,3 +374,10 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_bclk_pin(config[CONF_I2S_BCLK_PIN]))
if CONF_I2S_MCLK_PIN in config:
cg.add(var.set_mclk_pin(config[CONF_I2S_MCLK_PIN]))
if config[CONF_FULL_DUPLEX]:
cg.add_define("USE_I2S_AUDIO_FULL_DUPLEX")
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"i2s_audio.cpp": "USE_I2S_AUDIO_FULL_DUPLEX"}
)
@@ -0,0 +1,94 @@
#include "i2s_audio.h"
#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_FULL_DUPLEX)
#include "esphome/core/log.h"
namespace esphome::i2s_audio {
ESPHOME_LOG_TAG(TAG, "i2s_audio");
void I2SAudioComponent::setup() {
if (!this->is_full_duplex())
return;
// ESP-IDF only shares the bit and word clocks between channels allocated in the same call, and deleting either
// channel ends the pairing. Allocate both once here and keep them for the lifetime of the device.
i2s_chan_config_t chan_cfg{};
i2s_std_config_t tx_cfg{};
i2s_std_config_t rx_cfg{};
this->audio_out_->build_full_duplex_config(chan_cfg, tx_cfg);
this->audio_in_->build_full_duplex_config(rx_cfg);
esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, &this->rx_handle_);
if (err == ESP_OK) {
// The first initialized channel drives the shared clocks. Use RX so the microphone runs on its own.
err = i2s_channel_init_std_mode(this->rx_handle_, &rx_cfg);
}
if (err == ESP_OK) {
err = i2s_channel_init_std_mode(this->tx_handle_, &tx_cfg);
}
if (err != ESP_OK) {
ESP_LOGE(TAG, "Full duplex channel setup failed: %s", esp_err_to_name(err));
if (this->tx_handle_ != nullptr) {
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
}
if (this->rx_handle_ != nullptr) {
i2s_del_channel(this->rx_handle_);
this->rx_handle_ = nullptr;
}
this->mark_failed();
}
}
i2s_chan_handle_t I2SAudioComponent::acquire_(bool &in_use, i2s_chan_handle_t handle) {
if (handle == nullptr)
return nullptr;
in_use = true;
if (!this->update_rx_channel_()) {
in_use = false;
return nullptr;
}
return handle;
}
i2s_chan_handle_t I2SAudioComponent::acquire_rx_channel() { return this->acquire_(this->rx_in_use_, this->rx_handle_); }
void I2SAudioComponent::release_rx_channel() {
this->rx_in_use_ = false;
this->update_rx_channel_();
}
i2s_chan_handle_t I2SAudioComponent::acquire_tx_channel() {
// Speakers sharing the bus take turns; a second one must not get the channel while the first is playing
if (this->tx_in_use_)
return nullptr;
return this->acquire_(this->tx_in_use_, this->tx_handle_);
}
void I2SAudioComponent::release_tx_channel() {
// The speaker task may have left the channel disabled already, so an invalid state error is expected here
i2s_channel_disable(this->tx_handle_);
this->tx_in_use_ = false;
this->update_rx_channel_();
}
bool I2SAudioComponent::update_rx_channel_() {
const bool needed = this->rx_in_use_ || this->tx_in_use_;
if (needed == this->rx_enabled_)
return true;
esp_err_t err = needed ? i2s_channel_enable(this->rx_handle_) : i2s_channel_disable(this->rx_handle_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to %s RX channel: %s", needed ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable"),
esp_err_to_name(err));
return false;
}
this->rx_enabled_ = needed;
return true;
}
} // namespace esphome::i2s_audio
#endif // USE_ESP32 && USE_I2S_AUDIO_FULL_DUPLEX
+62 -2
View File
@@ -23,6 +23,15 @@ class I2SAudioBase : public Parented<I2SAudioComponent> {
void set_mclk_multiple(i2s_mclk_multiple_t mclk_multiple) { this->mclk_multiple_ = mclk_multiple; }
protected:
i2s_clock_src_t get_clock_source_() const {
#ifdef I2S_CLK_SRC_APLL
if (this->use_apll_) {
return I2S_CLK_SRC_APLL;
}
#endif
return I2S_CLK_SRC_DEFAULT;
}
i2s_role_t i2s_role_{};
i2s_slot_mode_t slot_mode_;
i2s_std_slot_mask_t std_slot_mask_;
@@ -32,12 +41,51 @@ class I2SAudioBase : public Parented<I2SAudioComponent> {
i2s_mclk_multiple_t mclk_multiple_;
};
class I2SAudioIn : public I2SAudioBase {};
class I2SAudioIn : public I2SAudioBase {
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
public:
/// @brief Builds the RX configuration the parent uses to set up a full duplex channel pair.
virtual void build_full_duplex_config(i2s_std_config_t &std_cfg) = 0;
#endif
};
class I2SAudioOut : public I2SAudioBase {};
class I2SAudioOut : public I2SAudioBase {
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
public:
/// @brief Builds the TX configuration the parent uses to set up a full duplex channel pair. The channel
/// configuration (DMA layout, role, interrupt priority) is shared by both channels. Validation only puts
/// standard speakers on a full duplex bus, so the SPDIF speaker never gets here.
virtual void build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) {}
#endif
};
class I2SAudioComponent final : public Component {
public:
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
void setup() override;
void set_audio_in(I2SAudioIn *audio_in) { this->audio_in_ = audio_in; }
void set_audio_out(I2SAudioOut *audio_out) { this->audio_out_ = audio_out; }
/// @brief True when a microphone and a speaker share this bus at the same time.
bool is_full_duplex() const { return this->audio_in_ != nullptr && this->audio_out_ != nullptr; }
/// @brief Enables the full duplex RX channel. Main loop only.
/// @return The enabled RX handle, or nullptr if the channel pair is unavailable
i2s_chan_handle_t acquire_rx_channel();
/// @brief Releases the RX channel; it keeps running while the TX side needs its clocks. Main loop only.
void release_rx_channel();
/// @brief Starts the shared clocks and hands over the full duplex TX channel, still disabled, so the caller
/// can register callbacks and preload data before enabling it. Main loop only.
/// @return The TX handle, or nullptr if the channel pair is unavailable or another speaker holds it
i2s_chan_handle_t acquire_tx_channel();
/// @brief Disables the TX channel and stops the shared clocks if the RX side is idle. Only the speaker that
/// acquired the channel may call this. Main loop only.
void release_tx_channel();
/// @brief True while another speaker holds the full duplex TX channel
bool is_tx_in_use() const { return this->tx_in_use_; }
#endif
i2s_std_gpio_config_t get_pin_config() const {
return {.mclk = (gpio_num_t) this->mclk_pin_,
.bclk = (gpio_num_t) this->bclk_pin_,
@@ -68,8 +116,20 @@ class I2SAudioComponent final : public Component {
protected:
Mutex lock_;
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
/// @brief Marks one side active and brings the shared clocks up; undoes the mark if that fails.
i2s_chan_handle_t acquire_(bool &in_use, i2s_chan_handle_t handle);
/// @brief Enables the RX channel while either side is active, since it drives the shared clocks.
bool update_rx_channel_();
I2SAudioIn *audio_in_{nullptr};
I2SAudioOut *audio_out_{nullptr};
i2s_chan_handle_t rx_handle_{nullptr};
i2s_chan_handle_t tx_handle_{nullptr};
bool rx_in_use_{false};
bool tx_in_use_{false};
bool rx_enabled_{false};
#endif
int mclk_pin_{I2S_GPIO_UNUSED};
int bclk_pin_{I2S_GPIO_UNUSED};
int lrclk_pin_;
@@ -46,6 +46,13 @@ void I2SAudioMicrophone::setup() {
return;
}
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
if (this->parent_->is_full_duplex() && this->parent_->is_failed()) {
this->mark_failed(LOG_STR("Full duplex bus setup failed"));
return;
}
#endif
this->configure_stream_settings_();
}
@@ -94,7 +101,44 @@ void I2SAudioMicrophone::start() {
xSemaphoreTake(this->active_listeners_semaphore_, 0);
}
void I2SAudioMicrophone::build_std_config_(i2s_std_config_t &std_cfg) const {
i2s_std_slot_config_t std_slot_cfg =
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_);
std_slot_cfg.slot_bit_width = this->slot_bit_width_;
std_slot_cfg.slot_mask = this->std_slot_mask_;
i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config();
pin_config.din = this->din_pin_;
std_cfg = {
.clk_cfg =
{
.sample_rate_hz = this->sample_rate_,
.clk_src = this->get_clock_source_(),
.mclk_multiple = this->mclk_multiple_,
},
.slot_cfg = std_slot_cfg,
.gpio_cfg = pin_config,
};
}
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
void I2SAudioMicrophone::build_full_duplex_config(i2s_std_config_t &std_cfg) { this->build_std_config_(std_cfg); }
#endif
bool I2SAudioMicrophone::start_driver_() {
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
if (this->parent_->is_full_duplex()) {
this->rx_handle_ = this->parent_->acquire_rx_channel();
if (this->rx_handle_ == nullptr) {
ESP_LOGE(TAG, "Full duplex channel unavailable");
return false;
}
this->configure_stream_settings_();
return true;
}
#endif
if (!this->parent_->try_lock()) {
return false; // Waiting for another i2s to return lock
}
@@ -115,18 +159,12 @@ bool I2SAudioMicrophone::start_driver_() {
return false;
}
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
#ifdef I2S_CLK_SRC_APLL
if (this->use_apll_) {
clk_src = I2S_CLK_SRC_APLL;
}
#endif
i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config();
#if SOC_I2S_SUPPORTS_PDM_RX
if (this->pdm_) {
i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config();
i2s_pdm_rx_clk_config_t clk_cfg = {
.sample_rate_hz = this->sample_rate_,
.clk_src = clk_src,
.clk_src = this->get_clock_source_(),
.mclk_multiple = this->mclk_multiple_,
.dn_sample_mode = this->pdm_dsr_,
};
@@ -162,23 +200,8 @@ bool I2SAudioMicrophone::start_driver_() {
} else
#endif
{
i2s_std_clk_config_t clk_cfg = {
.sample_rate_hz = this->sample_rate_,
.clk_src = clk_src,
.mclk_multiple = this->mclk_multiple_,
};
i2s_std_slot_config_t std_slot_cfg =
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_);
std_slot_cfg.slot_bit_width = this->slot_bit_width_;
std_slot_cfg.slot_mask = this->std_slot_mask_;
pin_config.din = this->din_pin_;
i2s_std_config_t std_cfg = {
.clk_cfg = clk_cfg,
.slot_cfg = std_slot_cfg,
.gpio_cfg = pin_config,
};
i2s_std_config_t std_cfg;
this->build_std_config_(std_cfg);
/* Initialize the channel */
err = i2s_channel_init_std_mode(this->rx_handle_, &std_cfg);
}
@@ -210,6 +233,17 @@ void I2SAudioMicrophone::stop_driver_() {
// There is no harm continuing to unload the driver if an error is ever returned by the various functions. This
// ensures that we stop/unload the driver when it only partially starts.
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
if (this->parent_->is_full_duplex()) {
// The parent owns the channel pair, so only hand the RX channel back
if (this->rx_handle_ != nullptr) {
this->parent_->release_rx_channel();
this->rx_handle_ = nullptr;
}
return;
}
#endif
esp_err_t err;
if (this->rx_handle_ != nullptr) {
/* Have to stop the channel before deleting it */
@@ -33,7 +33,14 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon
void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; }
#endif
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
void build_full_duplex_config(i2s_std_config_t &std_cfg) override;
#endif
protected:
/// @brief Builds the standard (non-PDM) mode configuration from the configured settings.
void build_std_config_(i2s_std_config_t &std_cfg) const;
/// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings.
/// @return True if succesful, false otherwise
bool start_driver_();
@@ -59,7 +66,7 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon
TaskHandle_t task_handle_{nullptr};
gpio_num_t din_pin_{I2S_GPIO_UNUSED};
i2s_chan_handle_t rx_handle_;
i2s_chan_handle_t rx_handle_{nullptr};
#if SOC_I2S_SUPPORTS_PDM_RX
i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S};
#endif
@@ -20,6 +20,7 @@ from esphome.types import ConfigType
from .. import (
CONF_I2S_AUDIO_ID,
CONF_I2S_COMM_FMT,
CONF_I2S_DOUT_PIN,
CONF_I2S_MODE,
CONF_LEFT,
@@ -27,6 +28,7 @@ from .. import (
CONF_MONO,
CONF_PRIMARY,
CONF_RIGHT,
CONF_SPDIF_MODE,
CONF_STEREO,
CONF_USE_APLL,
I2SAudioOut,
@@ -46,9 +48,7 @@ I2SAudioSpeakerBase = i2s_audio_ns.class_(
I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase)
CONF_DAC_TYPE = "dac_type"
CONF_I2S_COMM_FMT = "i2s_comm_fmt"
CONF_KEEP_ALIVE = "keep_alive"
CONF_SPDIF_MODE = "spdif_mode"
I2SAudioSpeakerBase = i2s_audio_ns.class_(
"I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut
@@ -467,14 +467,6 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
return ESP_ERR_INVALID_STATE;
}
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
#if SOC_CLK_APLL_SUPPORTED
if (this->use_apll_) {
clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL;
}
#endif // SOC_CLK_APLL_SUPPORTED
// SPDIF mode: fixed configuration for BMC encoding
// For new driver, dma_frame_num is in I2S frames (8 bytes each for 32-bit stereo)
uint32_t dma_buffer_length = SPDIF_BLOCK_I2S_FRAMES; // One SPDIF block = 384 I2S frames = 3072 bytes
@@ -496,7 +488,7 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
// SPDIF: double sample rate for BMC, 32-bit stereo, only data pin needed
i2s_std_clk_config_t clk_cfg = {
.sample_rate_hz = this->sample_rate_ * 2,
.clk_src = clk_src,
.clk_src = this->get_clock_source_(),
.mclk_multiple = this->mclk_multiple_,
};
@@ -41,6 +41,17 @@ void I2SAudioSpeakerBase::setup() {
this->set_volume(this->volume_);
this->set_mute_state(this->mute_state_);
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
// The parent set up before us and already routed dout to the shared TX channel, which is never re-initialized.
// Parking would detach the pin for good. SPDIF speakers cannot use a full duplex bus.
if (this->parent_->is_full_duplex()) {
if (this->parent_->is_failed()) {
this->mark_failed(LOG_STR("Full duplex bus setup failed"));
}
return;
}
#endif
// Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG
// function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now.
this->park_dout_pin_();
@@ -130,7 +141,7 @@ void I2SAudioSpeakerBase::loop() {
// Handle the speaker's state
switch (this->state_) {
case speaker::STATE_STARTING:
case speaker::STATE_STARTING: {
if (this->status_has_error()) {
break;
}
@@ -140,7 +151,21 @@ void I2SAudioSpeakerBase::loop() {
break;
}
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
if (this->parent_->is_tx_in_use()) {
// Another speaker on the bus is playing; wait for it to release the channel
break;
}
#endif
const esp_err_t err = this->start_i2s_driver(this->audio_stream_info_);
if (err == ESP_ERR_NOT_SUPPORTED) {
// Retrying cannot help; stay stopped so the audio source gives up instead of waiting forever
ESP_LOGE(TAG, "Stream format not supported; not starting");
this->rejected_stream_info_ = this->audio_stream_info_;
this->state_ = speaker::STATE_STOPPED;
break;
}
if (err != ESP_OK) {
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
this->status_momentary_error(1000);
break;
@@ -159,6 +184,7 @@ void I2SAudioSpeakerBase::loop() {
this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt
}
break;
}
case speaker::STATE_RUNNING: // Intentional fallthrough
case speaker::STATE_STOPPING: // Intentional fallthrough
case speaker::STATE_STOPPED:
@@ -240,6 +266,9 @@ void I2SAudioSpeakerBase::start() {
return;
if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING))
return;
// play() calls start() on every write, so without this a rejected stream would be retried and logged nonstop
if (this->rejected_stream_info_.has_value() && this->rejected_stream_info_.value() == this->audio_stream_info_)
return;
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
}
@@ -277,6 +306,15 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c
return err;
}
err = this->prepare_event_queues_(event_queue_size);
if (err != ESP_OK) {
this->delete_channel_();
this->parent_->unlock();
}
return err;
}
esp_err_t I2SAudioSpeakerBase::prepare_event_queues_(size_t event_queue_size) {
// Both queues are drained by the speaker task before it preloads, whether the channel is new or kept
if (this->i2s_event_queue_ == nullptr) {
this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t));
@@ -290,15 +328,41 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c
if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) {
ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)");
this->delete_channel_();
this->parent_->unlock();
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
esp_err_t I2SAudioSpeakerBase::acquire_full_duplex_channel_(size_t event_queue_size) {
this->tx_handle_ = this->parent_->acquire_tx_channel();
if (this->tx_handle_ == nullptr) {
ESP_LOGE(TAG, "Full duplex channel unavailable");
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = this->prepare_event_queues_(event_queue_size);
if (err != ESP_OK) {
this->parent_->release_tx_channel();
this->tx_handle_ = nullptr;
}
return err;
}
#endif
void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) {
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
if (this->parent_->is_full_duplex()) {
// The parent owns the channel pair and its pins, so only hand the TX channel back
if (this->tx_handle_ != nullptr) {
this->parent_->release_tx_channel();
this->tx_handle_ = nullptr;
}
return;
}
#endif
if (this->tx_handle_ != nullptr) {
i2s_channel_disable(this->tx_handle_);
// A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and
@@ -372,9 +436,9 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea
this->current_stream_info_.bytes_to_samples(bytes_read));
}
bool I2SAudioSpeakerBase::esp32_mono_swap_needed_() const {
bool I2SAudioSpeakerBase::esp32_mono_swap_needed(const audio::AudioStreamInfo &stream_info) {
#ifdef USE_ESP32_VARIANT_ESP32
return this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16;
return stream_info.get_channels() == 1 && stream_info.get_bits_per_sample() == 16;
#else
return false;
#endif // USE_ESP32_VARIANT_ESP32
@@ -127,6 +127,18 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
size_t event_queue_size);
/// @brief Creates the lockstep queues unless they exist already.
/// @param event_queue_size Size of each queue
/// @return ESP_OK if successful, or ESP_ERR_NO_MEM
esp_err_t prepare_event_queues_(size_t event_queue_size);
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
/// @brief Takes the TX channel of the parent's full duplex pair and prepares the event queues.
/// @param event_queue_size Size of the event queue
/// @return ESP_OK if successful, or an error code. On failure, hands the channel back to the parent.
esp_err_t acquire_full_duplex_channel_(size_t event_queue_size);
#endif
/// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated
void stop_i2s_driver_(bool keep_channel);
@@ -160,8 +172,10 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
/// @param bytes_read Number of bytes of audio data
void apply_software_volume_(uint8_t *data, size_t bytes_read);
/// @brief Whether 16-bit mono samples in this format need the ESP32 (non-variant) pair swap.
static bool esp32_mono_swap_needed(const audio::AudioStreamInfo &stream_info);
/// @brief Whether the output is 16-bit mono on the original ESP32, which sends each pair of samples swapped.
bool esp32_mono_swap_needed_() const;
bool esp32_mono_swap_needed_() const { return esp32_mono_swap_needed(this->output_stream_info_); }
/// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk.
/// Only applies when esp32_mono_swap_needed_() is true. `data` must start on a DMA word and hold whole pairs.
@@ -182,6 +196,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
optional<uint32_t> timeout_;
// A format start_i2s_driver() refused. Those checks compare against fixed settings, so it can never start.
optional<audio::AudioStreamInfo> rejected_stream_info_;
bool pause_state_{false};
bool keep_alive_{false};
@@ -86,7 +86,7 @@ void I2SAudioSpeaker::run_speaker_task() {
// The ring buffer holds input-format audio (what play() receives), so size it from the input stream info.
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
// Hand over whole ESP32 mono sample pairs so writes stay word-aligned
const size_t source_alignment = bytes_per_frame * this->dma_frame_multiple_();
const size_t source_alignment = bytes_per_frame * dma_frame_multiple(this->output_stream_info_);
const bool pair_aligned = source_alignment > bytes_per_frame;
// Round the ring buffer size down to a multiple of source_alignment so the wrap boundary avoids splices.
const size_t ring_buffer_size =
@@ -111,7 +111,7 @@ void I2SAudioSpeaker::run_speaker_task() {
} else {
// Should not happen for a READY channel; fall back to the requested size.
dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(
dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_()));
dma_buffer_frames(this->output_stream_info_, dma_frame_multiple(this->output_stream_info_)));
}
// dma_buffer_bytes counts output-format bytes; convert with the output stream info.
const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes);
@@ -442,6 +442,25 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
}
#endif // USE_ESP32_VARIANT_ESP32
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
if (this->parent_->is_full_duplex()) {
// The parent set up the channel once from the configured format, so the stream must produce exactly that
const audio::AudioStreamInfo required = this->full_duplex_stream_info_();
if (this->output_stream_info_ != required) {
ESP_LOGE(TAG, "Full duplex requires %u-bit, %u channel audio at %" PRIu32 " Hz",
(unsigned) required.get_bits_per_sample(), (unsigned) required.get_channels(),
required.get_sample_rate());
if (this->output_stream_info_.get_channels() != required.get_channels()) {
ESP_LOGE(TAG, "A resampler does not change the channel count; add a mixer speaker with num_channels: %u",
(unsigned) required.get_channels());
}
return ESP_ERR_NOT_SUPPORTED;
}
// The speaker task will enable the channel after preloading.
return this->acquire_full_duplex_channel_(I2S_EVENT_QUEUE_COUNT);
}
#endif // USE_I2S_AUDIO_FULL_DUPLEX
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
return ESP_ERR_INVALID_STATE;
@@ -457,25 +476,37 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
this->current_stream_info_ = audio_stream_info;
i2s_chan_config_t chan_cfg;
i2s_std_config_t std_cfg;
this->build_i2s_config_(this->output_stream_info_, chan_cfg, std_cfg);
// The speaker task will enable the channel after preloading.
return this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT);
}
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
audio::AudioStreamInfo I2SAudioSpeaker::full_duplex_stream_info_() const {
return audio::AudioStreamInfo(static_cast<uint8_t>(this->slot_bit_width_),
this->slot_mode_ == I2S_SLOT_MODE_STEREO ? 2 : 1, this->sample_rate_);
}
void I2SAudioSpeaker::build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) {
this->build_i2s_config_(this->full_duplex_stream_info_(), chan_cfg, std_cfg);
}
#endif // USE_I2S_AUDIO_FULL_DUPLEX
void I2SAudioSpeaker::build_i2s_config_(const audio::AudioStreamInfo &output_stream_info, i2s_chan_config_t &chan_cfg,
i2s_std_config_t &std_cfg) const {
// The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info.
uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_());
i2s_role_t i2s_role = this->i2s_role_;
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
#if SOC_CLK_APLL_SUPPORTED
if (this->use_apll_) {
clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL;
}
#endif // SOC_CLK_APLL_SUPPORTED
uint32_t dma_buffer_length = dma_buffer_frames(output_stream_info, dma_frame_multiple(output_stream_info));
// Log DMA configuration for debugging
ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT,
(unsigned long) dma_buffer_length);
i2s_chan_config_t chan_cfg = {
chan_cfg = {
.id = this->parent_->get_port(),
.role = i2s_role,
.role = this->i2s_role_,
.dma_desc_num = DMA_BUFFERS_COUNT,
.dma_frame_num = dma_buffer_length,
.auto_clear = true,
@@ -484,22 +515,22 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
// Build standard I2S clock/slot/gpio configuration
i2s_std_clk_config_t clk_cfg = {
.sample_rate_hz = audio_stream_info.get_sample_rate(),
.clk_src = clk_src,
.sample_rate_hz = output_stream_info.get_sample_rate(),
.clk_src = this->get_clock_source_(),
.mclk_multiple = this->mclk_multiple_,
};
i2s_slot_mode_t slot_mode = this->slot_mode_;
i2s_std_slot_mask_t slot_mask = this->std_slot_mask_;
if (audio_stream_info.get_channels() == 1) {
if (output_stream_info.get_channels() == 1) {
slot_mode = I2S_SLOT_MODE_MONO;
} else if (audio_stream_info.get_channels() == 2) {
} else if (output_stream_info.get_channels() == 2) {
slot_mode = I2S_SLOT_MODE_STEREO;
slot_mask = I2S_STD_SLOT_BOTH;
}
// Configure the data bit width from the output (post-narrowing) format, which is what is clocked out.
const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample();
const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) output_stream_info.get_bits_per_sample();
i2s_std_slot_config_t slot_cfg;
switch (this->i2s_comm_fmt_) {
case I2SCommFmt::PCM:
@@ -535,20 +566,11 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config();
gpio_cfg.dout = this->dout_pin_;
i2s_std_config_t std_cfg = {
std_cfg = {
.clk_cfg = clk_cfg,
.slot_cfg = slot_cfg,
.gpio_cfg = gpio_cfg,
};
esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT);
if (err != ESP_OK) {
return err;
}
// The speaker task will enable the channel after preloading.
return ESP_OK;
}
} // namespace esphome::i2s_audio
@@ -20,12 +20,28 @@ class I2SAudioSpeaker final : public I2SAudioSpeakerBase {
void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; }
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
void build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) override;
#endif
protected:
void run_speaker_task() override;
esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override;
/// @brief Builds the channel and standard mode configuration for the given output format.
/// @param output_stream_info Format clocked out of the I2S peripheral
void build_i2s_config_(const audio::AudioStreamInfo &output_stream_info, i2s_chan_config_t &chan_cfg,
i2s_std_config_t &std_cfg) const;
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
/// @brief The output format of a full duplex bus, fixed by the configuration since the channel is set up once.
audio::AudioStreamInfo full_duplex_stream_info_() const;
#endif
/// @brief Frame multiple for DMA buffers and writes; 2 keeps ESP32 mono sample pairs word-aligned.
uint32_t dma_frame_multiple_() const { return this->esp32_mono_swap_needed_() ? 2 : 1; }
static uint32_t dma_frame_multiple(const audio::AudioStreamInfo &stream_info) {
return esp32_mono_swap_needed(stream_info) ? 2 : 1;
}
I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD};
};
+1
View File
@@ -95,6 +95,7 @@
#define USE_HOMEASSISTANT_TIME
#define USE_HOMEASSISTANT_TIMEZONE
#define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT
#define USE_I2S_AUDIO_FULL_DUPLEX
#define USE_I2S_AUDIO_SPDIF_MODE
#define USE_IMAGE
#define USE_IMPROV_BLE_STATE_CALLBACK
@@ -0,0 +1,42 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
i2s_audio:
- id: duplex_bus
i2s_bclk_pin: GPIO5
i2s_lrclk_pin: GPIO4
full_duplex: true
microphone:
- platform: i2s_audio
id: duplex_mic
i2s_audio_id: duplex_bus
i2s_din_pin: GPIO16
adc_type: external
sample_rate: 48000
bits_per_sample: 32bit
speaker:
- platform: i2s_audio
id: duplex_speaker_a
i2s_audio_id: duplex_bus
i2s_dout_pin:
number: GPIO17
allow_other_uses: true
dac_type: external
sample_rate: 48000
bits_per_sample: 32bit
- platform: i2s_audio
id: duplex_speaker_b
i2s_audio_id: duplex_bus
i2s_dout_pin:
number: GPIO17
allow_other_uses: true
dac_type: external
sample_rate: 48000
bits_per_sample: 32bit
@@ -0,0 +1,141 @@
"""Tests for the i2s_audio full duplex bus."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.i2s_audio import _validate_full_duplex
from esphome.core import CORE, ID
BUS_ID = "duplex_bus"
def _device(**overrides: Any) -> dict[str, Any]:
return {
"platform": "i2s_audio",
"id": ID("device", is_declaration=True),
"i2s_audio_id": ID(BUS_ID, is_declaration=False),
"i2s_dout_pin": 17,
"channel": "stereo",
"i2s_comm_fmt": "stand_i2s",
"sample_rate": 48000,
"bits_per_sample": 32,
"i2s_mode": "primary",
"use_apll": False,
"mclk_multiple": 256,
} | overrides
def _full_config(
microphones: list[dict[str, Any]], speakers: list[dict[str, Any]]
) -> dict[str, Any]:
return {"microphone": microphones, "speaker": speakers}
def test_full_duplex_generates_code(
generate_main: Callable[[str | Path], str],
) -> None:
"""The microphone and every speaker are registered with the bus and the full duplex code is enabled."""
main_cpp = generate_main(
"tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml"
)
assert "duplex_bus->set_audio_in(duplex_mic);" in main_cpp
assert "duplex_bus->set_audio_out(duplex_speaker_a);" in main_cpp
assert "duplex_bus->set_audio_out(duplex_speaker_b);" in main_cpp
assert "USE_I2S_AUDIO_FULL_DUPLEX" in {define.name for define in CORE.defines}
_OTHER_BUS = _device(i2s_audio_id=ID("other_bus", is_declaration=False))
@pytest.mark.parametrize(
("microphones", "speakers"),
[
pytest.param([_device(pdm=False)], [_device(spdif_mode=False)], id="matching"),
pytest.param(
[_device(), _OTHER_BUS], [_device(), _OTHER_BUS], id="other-bus-ignored"
),
pytest.param(
[_device()], [_device(), _device(), _device()], id="several-speakers"
),
],
)
def test_full_duplex_accepts(
microphones: list[dict[str, Any]], speakers: list[dict[str, Any]]
) -> None:
"""Devices with the same clock and TX settings share the bus; other buses do not count."""
_validate_full_duplex(_full_config(microphones, speakers), BUS_ID)
@pytest.mark.parametrize(
("microphones", "speakers"),
[
([], [_device()]),
([_device()], []),
([_device(), _device()], [_device()]),
],
)
def test_full_duplex_requires_one_microphone_and_a_speaker(
microphones: list[dict[str, Any]], speakers: list[dict[str, Any]]
) -> None:
"""Exactly one microphone and at least one speaker must use the bus."""
with pytest.raises(cv.Invalid, match="exactly one i2s_audio microphone"):
_validate_full_duplex(_full_config(microphones, speakers), BUS_ID)
@pytest.mark.parametrize(
("microphone", "speaker", "error_match"),
[
pytest.param(_device(pdm=True), _device(), "PDM microphone", id="pdm"),
pytest.param(_device(), _device(spdif_mode=True), "SPDIF speaker", id="spdif"),
],
)
def test_full_duplex_rejects_unsupported_devices(
microphone: dict[str, Any], speaker: dict[str, Any], error_match: str
) -> None:
"""PDM receive and SPDIF output cannot share the standard mode clocks."""
with pytest.raises(cv.Invalid, match=error_match):
_validate_full_duplex(_full_config([microphone], [speaker]), BUS_ID)
@pytest.mark.parametrize(
("key", "value"),
[
("sample_rate", 16000),
("bits_per_sample", 16),
("i2s_mode", "secondary"),
("use_apll", True),
("mclk_multiple", 384),
],
)
def test_full_duplex_rejects_mismatched_clock_settings(key: str, value: Any) -> None:
"""Settings that shape the shared clocks must match."""
with pytest.raises(cv.Invalid, match=f"must use the same '{key}'"):
_validate_full_duplex(
_full_config([_device()], [_device(**{key: value})]), BUS_ID
)
@pytest.mark.parametrize(
("key", "value"),
[
("i2s_dout_pin", 18),
("channel", "mono"),
("i2s_comm_fmt", "stand_msb"),
],
)
def test_full_duplex_rejects_mismatched_speakers(key: str, value: Any) -> None:
"""Settings that shape the TX channel must match across speakers."""
with pytest.raises(
cv.Invalid,
match=f"speakers on 'full_duplex' bus '{BUS_ID}' must use the same '{key}'",
):
_validate_full_duplex(
_full_config([_device()], [_device(), _device(**{key: value})]), BUS_ID
)
@@ -0,0 +1,26 @@
i2s_audio:
- id: i2s_duplex_bus
i2s_bclk_pin: ${i2s_bclk_pin}
i2s_lrclk_pin: ${i2s_lrclk_pin}
i2s_mclk_pin: ${i2s_mclk_pin}
full_duplex: true
microphone:
- platform: i2s_audio
id: duplex_mic
i2s_audio_id: i2s_duplex_bus
i2s_din_pin: ${i2s_din_pin}
adc_type: external
sample_rate: 48000
bits_per_sample: 32bit
channel: stereo
speaker:
- platform: i2s_audio
id: duplex_speaker
i2s_audio_id: i2s_duplex_bus
i2s_dout_pin: ${i2s_dout_pin}
dac_type: external
sample_rate: 48000
bits_per_sample: 32bit
channel: stereo
@@ -0,0 +1,9 @@
substitutions:
i2s_bclk_pin: GPIO5
i2s_lrclk_pin: GPIO4
i2s_mclk_pin: GPIO15
i2s_din_pin: GPIO16
i2s_dout_pin: GPIO17
packages:
i2s_audio: !include common-full_duplex.yaml
@@ -0,0 +1,9 @@
substitutions:
i2s_bclk_pin: GPIO5
i2s_lrclk_pin: GPIO4
i2s_mclk_pin: GPIO15
i2s_din_pin: GPIO16
i2s_dout_pin: GPIO17
packages:
i2s_audio: !include common-full_duplex.yaml