diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 6e4e35590c1..3d5ec97a633 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -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"} +) diff --git a/esphome/components/i2s_audio/i2s_audio.cpp b/esphome/components/i2s_audio/i2s_audio.cpp new file mode 100644 index 00000000000..d1ee8d0c73b --- /dev/null +++ b/esphome/components/i2s_audio/i2s_audio.cpp @@ -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 diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index 00a9705807e..b9adbe45e23 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -23,6 +23,15 @@ class I2SAudioBase : public Parented { 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 { 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_; diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 1345f0187b3..526ae304ee2 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -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 */ diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 1b17380ed87..c5ed52f6514 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -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 diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index e4ea186199d..ddbe08922fe 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -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 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 3938d777717..46d6473983d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -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_, }; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 85c0e6358a2..f14626dd47f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -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 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 07b7e5d1905..03213347f0e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -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 timeout_; + // A format start_i2s_driver() refused. Those checks compare against fixed settings, so it can never start. + optional rejected_stream_info_; + bool pause_state_{false}; bool keep_alive_{false}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 16abb2f22ef..73ad924fad7 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -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(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 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 01390dfdf70..fb1184bb71e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -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}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c901ff3e844..0655c4a399c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml b/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml new file mode 100644 index 00000000000..5cc4fb38125 --- /dev/null +++ b/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml @@ -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 diff --git a/tests/component_tests/i2s_audio/test_i2s_audio.py b/tests/component_tests/i2s_audio/test_i2s_audio.py new file mode 100644 index 00000000000..f92d0ca9414 --- /dev/null +++ b/tests/component_tests/i2s_audio/test_i2s_audio.py @@ -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 + ) diff --git a/tests/components/i2s_audio/common-full_duplex.yaml b/tests/components/i2s_audio/common-full_duplex.yaml new file mode 100644 index 00000000000..06ec6a87c96 --- /dev/null +++ b/tests/components/i2s_audio/common-full_duplex.yaml @@ -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 diff --git a/tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml b/tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml new file mode 100644 index 00000000000..4e8c9b4e4c8 --- /dev/null +++ b/tests/components/i2s_audio/test-full_duplex.esp32-idf.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 diff --git a/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml b/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml new file mode 100644 index 00000000000..4e8c9b4e4c8 --- /dev/null +++ b/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.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