[i2s_audio] Refactor SPDIF output, fixing synchronization problems (#16319)

This commit is contained in:
Kevin Ahrendt
2026-05-08 21:26:09 +00:00
committed by GitHub
parent 88c2a1c096
commit 70b9edfabe
5 changed files with 206 additions and 311 deletions
File diff suppressed because it is too large Load Diff
@@ -69,6 +69,17 @@ void I2SAudioSpeakerBase::loop() {
}
if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) {
ESP_LOGV(TAG, "Stopping");
// Lockstep-breaking error bits are latched by the task and cleared along with all other bits
// when TASK_STOPPED is processed; log them here, exactly once, as the task winds down.
if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) {
ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync");
}
if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) {
ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task");
}
if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) {
ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task");
}
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
this->state_ = speaker::STATE_STOPPING;
}
@@ -87,18 +98,11 @@ void I2SAudioSpeakerBase::loop() {
this->state_ = speaker::STATE_STOPPED;
}
// Log any errors encountered by the task
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
ESP_LOGE(TAG, "Not enough memory");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
}
// Warn if any playback timestamp events are dropped, which drastically reduces synced playback accuracy
if (event_group_bits & SpeakerEventGroupBits::WARN_DROPPED_EVENT) {
ESP_LOGW(TAG, "Event dropped, synchronized playback accuracy is reduced");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::WARN_DROPPED_EVENT);
}
// Handle the speaker's state
switch (this->state_) {
case speaker::STATE_STARTING:
@@ -271,6 +275,22 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c
xQueueReset(this->i2s_event_queue_);
}
// Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue
// so a fully-saturated DMA pipeline cannot overflow either side before drain.
if (this->write_records_queue_ == nullptr) {
this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t));
} else {
xQueueReset(this->write_records_queue_);
}
if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) {
ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)");
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
this->parent_->unlock();
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
@@ -293,10 +313,16 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s
I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx;
if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) {
// Queue is full, so discard the oldest event and set the warning flag to inform the user
// Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_``
// and any per-buffer record queue maintained by the task are permanently desynced, so the task
// must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP
// (so the task bails immediately, closing the race where loop() could clear the error bit
// before the task observes it).
int64_t dummy;
xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1);
xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::WARN_DROPPED_EVENT, &need_yield2);
xEventGroupSetBitsFromISR(this_speaker->event_group_,
SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP,
&need_yield2);
}
xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3);
@@ -35,7 +35,11 @@ enum SpeakerEventGroupBits : uint32_t {
ERR_ESP_NO_MEM = (1 << 19),
WARN_DROPPED_EVENT = (1 << 20),
ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event
ERR_PARTIAL_WRITE = (1 << 21), // a DMA write returned fewer bytes than requested (or the encoder
// failed to commit a complete block), which breaks the lockstep
// invariant for every subsequent event
ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
};
@@ -141,7 +145,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
TaskHandle_t speaker_task_handle_{nullptr};
EventGroupHandle_t event_group_{nullptr};
// Lockstepped DMA buffer queues: i2s_event is outgoing, write_records is incoming
QueueHandle_t i2s_event_queue_{nullptr};
QueueHandle_t write_records_queue_{nullptr};
std::weak_ptr<ring_buffer::RingBuffer> audio_ring_buffer_;
@@ -358,25 +358,15 @@ HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ti
}
esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) {
// First, send any pending complete block from a previous failed send
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
return err;
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty)
// and send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
static const uint8_t SILENCE[2] = {0, 0};
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(SILENCE);
}
}
if (!this->has_pending_data()) {
return ESP_OK; // Nothing to flush
}
// Encode silence (zeros) until the block is complete
static const uint8_t SILENCE[2] = {0, 0};
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(SILENCE);
}
return this->send_block_(ticks_to_wait);
}
@@ -85,9 +85,10 @@ class SPDIFEncoder {
/// @brief Check if there is a partial block pending
bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); }
/// @brief Flush any pending partial block by padding with silence and sending
/// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send,
/// or send a full silence block if nothing is pending. Always produces exactly one block on success.
/// @param ticks_to_wait Timeout for blocking writes
/// @return esp_err_t as returned from the callback, or ESP_OK if nothing to flush
/// @return esp_err_t as returned from the callback
esp_err_t flush_with_silence(TickType_t ticks_to_wait);
/// @brief Reset the SPDIF block buffer and position tracking, discarding any partial block