From a72edf242f1169cdeba44ee556ef8de08076465d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 15:43:00 -0500 Subject: [PATCH 01/20] fix(esp-box): wait for a whole audio block instead of padding with silence The I2S feed task took whatever was queued up to one block each time it woke and zero-filled the rest. The block is sized for the default 48 kHz (sample_rate * 4 / 60 = 3200 bytes), which is 30 ms of audio at the rates emulators use (e.g. 26.6 kHz for a Genesis), and producers push one video frame of audio (~16.7 ms) at a time with frame-to-frame jitter -- so any moment the stream held less than a block turned into up to a block of injected silence, audible as stutter whenever the producer's cadence was uneven (esp-box-emu, Sonic 3 special stages: measured ~1000 padded blocks per session). Create the stream buffer with a trigger level of one block and receive with a bounded timeout, so the task waits for a whole block while audio is flowing; after a timeout (producer idle: menu, pause) write what was received, or a block of silence when there is nothing, which keeps the DMA fed. Partial writes are concatenated by the I2S driver without gaps. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/esp-box/src/audio.cpp | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/components/esp-box/src/audio.cpp b/components/esp-box/src/audio.cpp index 48d154e3bc..f0a16b0146 100644 --- a/components/esp-box/src/audio.cpp +++ b/components/esp-box/src/audio.cpp @@ -116,7 +116,9 @@ bool EspBox::initialize_i2s(uint32_t default_audio_rate) { auto buffer_size = calc_audio_buffer_size(default_audio_rate); audio_tx_buffer.resize(buffer_size); - audio_tx_stream = xStreamBufferCreate(buffer_size * 4, 0); + // Trigger level = one block: the consumer waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = xStreamBufferCreate(buffer_size * 4, buffer_size); xStreamBufferReset(audio_tx_stream); @@ -158,18 +160,24 @@ bool EspBox::initialize_sound(uint32_t default_audio_rate, void EspBox::enable_sound(bool enable) { gpio_set_level(sound_power_pin, enable); } bool EspBox::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - // Queue the next I2S out frame to write - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); + // Queue the next I2S out block. Wait (bounded) for a whole block rather than + // taking a partial one and padding it with silence: producers such as the + // emulators push one video frame of audio at a time with frame-to-frame + // jitter, and the stream (4 blocks) exists to absorb exactly that. Taking a + // partial block injected up to a block of silence every time the stream + // momentarily held less than one -- audible as stutter whenever the producer's + // cadence was uneven. If the producer is idle (menu, pause) the timeout keeps + // the DMA fed with silence; a partial block after the timeout is written as + // is (the I2S driver concatenates partial writes without gaps). size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); uint8_t *buffer = &audio_tx_buffer[0]; - memset(buffer, 0, buffer_size); - - if (available == 0) { + size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, pdMS_TO_TICKS(40)); + got -= got % 4; // whole 16-bit stereo frames only + if (got == 0) { + memset(buffer, 0, buffer_size); i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); } else { - xStreamBufferReceive(audio_tx_stream, buffer, available, 0); - i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); + i2s_channel_write(audio_tx_handle, buffer, got, NULL, portMAX_DELAY); } return false; // don't stop the task } From d493f2d11e646bbaf2301078e1f1c98344e8e953 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:32 -0500 Subject: [PATCH 02/20] fix(esp32-p4-eth): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/esp32-p4-eth/src/audio.cpp | 39 +++++++++++++++------------ 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/components/esp32-p4-eth/src/audio.cpp b/components/esp32-p4-eth/src/audio.cpp index 1669e1258c..d7a0ca7f02 100644 --- a/components/esp32-p4-eth/src/audio.cpp +++ b/components/esp32-p4-eth/src/audio.cpp @@ -113,7 +113,10 @@ bool Esp32P4Eth::initialize_audio(uint32_t sample_rate, const espp::Task::BaseCo // whole UI sound clip fits and play_audio() can enqueue it without blocking. auto tx_buf_size = calc_audio_buffer_size(sample_rate); audio_tx_buffer.resize(tx_buf_size); - audio_tx_stream = xStreamBufferCreate(std::max(tx_buf_size * 4, 64 * 1024), 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = + xStreamBufferCreate(std::max(tx_buf_size * 4, 64 * 1024), tx_buf_size); if (audio_tx_stream == nullptr) { return fail_audio_init("Failed to allocate the audio TX stream buffer"); } @@ -296,27 +299,29 @@ float Esp32P4Eth::microphone_volume() const { return mic_volume_; } bool Esp32P4Eth::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); + uint8_t *tx_buf = audio_tx_buffer.data(); + // Wait (bounded) for a whole block rather than taking whatever is queued and + // zero-padding it to a block: producers push audio in bursts (one video frame + // at a time, with frame-to-frame jitter), and the stream buffer exists to + // absorb exactly that. Taking a partial block padded it with silence every + // time the stream momentarily held less than one block, which is audible as + // stutter whenever the producer's cadence is uneven. The DMA is still fed + // whole blocks at a constant cadence while audio is flowing (the stream's + // trigger level is one block); only after the timeout -- producer idle or + // slower than real time -- is a partial block written as is (the I2S driver + // concatenates partial writes without gaps), or silence when there is none. + // Finite timeouts (not portMAX_DELAY) so this task returns periodically and + // can observe a stop request during teardown. + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); // only ever hand whole 16-bit samples to I2S; a partial sample would shift // the framing of everything after it - available &= ~static_cast(1); - uint8_t *tx_buf = audio_tx_buffer.data(); - memset(tx_buf, 0, buffer_size); - // Use a finite write timeout (not portMAX_DELAY) so this task returns - // periodically and can observe a stop request; an infinite write would block - // Task::stop() from joining during teardown if the I2S sink ever stalls. - if (available == 0) { + got &= ~static_cast(1); + if (got == 0) { + memset(tx_buf, 0, buffer_size); i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, pdMS_TO_TICKS(100)); } else { - xStreamBufferReceive(audio_tx_stream, tx_buf, available, 0); - // Always write a full, frame-aligned buffer (queued samples zero-padded to - // buffer_size) so the I2S DMA is fed at a constant cadence - matching the - // esp-box / t-deck / m5stack-tab5 playback path. Writing only `available` - // bytes makes the drain cadence variable and interleaves whole frames of - // silence into bursty streams, which sounds choppy/glitchy. - i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, pdMS_TO_TICKS(100)); + i2s_channel_write(audio_tx_handle, tx_buf, got, NULL, pdMS_TO_TICKS(100)); } // honor a stop request per the Task contract: check/clear notified under m std::unique_lock lock(m); From 0f0823ea40868cb90084d44e0159d51ce6c2e16e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:34 -0500 Subject: [PATCH 03/20] fix(esp32-p4-function-ev-board): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../esp32-p4-function-ev-board/src/audio.cpp | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/components/esp32-p4-function-ev-board/src/audio.cpp b/components/esp32-p4-function-ev-board/src/audio.cpp index 511082d4fa..49554f4bff 100644 --- a/components/esp32-p4-function-ev-board/src/audio.cpp +++ b/components/esp32-p4-function-ev-board/src/audio.cpp @@ -95,7 +95,10 @@ bool Esp32P4FunctionEvBoard::initialize_audio(uint32_t sample_rate, // whole UI sound clip fits and play_audio() can enqueue it without blocking. auto tx_buf_size = calc_audio_buffer_size(sample_rate); audio_tx_buffer.resize(tx_buf_size); - audio_tx_stream = xStreamBufferCreate(std::max(tx_buf_size * 4, 64 * 1024), 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = + xStreamBufferCreate(std::max(tx_buf_size * 4, 64 * 1024), tx_buf_size); xStreamBufferReset(audio_tx_stream); using namespace std::placeholders; @@ -261,24 +264,27 @@ float Esp32P4FunctionEvBoard::microphone_volume() const { return mic_volume_; } bool Esp32P4FunctionEvBoard::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); + uint8_t *tx_buf = audio_tx_buffer.data(); + // Wait (bounded) for a whole block rather than taking whatever is queued and + // zero-padding it to a block: producers push audio in bursts (one video frame + // at a time, with frame-to-frame jitter), and the stream buffer exists to + // absorb exactly that. Taking a partial block padded it with silence every + // time the stream momentarily held less than one block, which is audible as + // stutter whenever the producer's cadence is uneven. The DMA is still fed + // whole blocks at a constant cadence while audio is flowing (the stream's + // trigger level is one block); only after the timeout -- producer idle or + // slower than real time -- is a partial block written as is (the I2S driver + // concatenates partial writes without gaps), or silence when there is none. + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); // only ever hand whole 16-bit samples to I2S; a partial sample would shift // the framing of everything after it - available &= ~static_cast(1); - uint8_t *tx_buf = audio_tx_buffer.data(); - memset(tx_buf, 0, buffer_size); - if (available == 0) { + got &= ~static_cast(1); + if (got == 0) { + memset(tx_buf, 0, buffer_size); i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, portMAX_DELAY); } else { - xStreamBufferReceive(audio_tx_stream, tx_buf, available, 0); - // Always write a full, frame-aligned buffer (queued samples zero-padded to - // buffer_size) so the I2S DMA is fed at a constant cadence - matching the - // esp-box / t-deck / m5stack-tab5 playback path. Writing only `available` - // bytes makes the drain cadence variable and interleaves whole frames of - // silence into bursty streams, which sounds choppy/glitchy. - i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, portMAX_DELAY); + i2s_channel_write(audio_tx_handle, tx_buf, got, NULL, portMAX_DELAY); } return false; } From 2c68d18dfa5e31a5b5b94d0371fc11a73d9c8d31 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:35 -0500 Subject: [PATCH 04/20] fix(esp32-p4-module-dev-kit): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../esp32-p4-module-dev-kit/src/audio.cpp | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/components/esp32-p4-module-dev-kit/src/audio.cpp b/components/esp32-p4-module-dev-kit/src/audio.cpp index c219f9beea..b89b3eb045 100644 --- a/components/esp32-p4-module-dev-kit/src/audio.cpp +++ b/components/esp32-p4-module-dev-kit/src/audio.cpp @@ -189,7 +189,10 @@ bool Esp32P4ModuleDevKit::initialize_audio(uint32_t sample_rate, // chunks (see the example's playback loop). auto tx_buf_size = calc_audio_buffer_size(sample_rate, TX_NUM_CHANNELS); audio_tx_buffer.resize(tx_buf_size); - audio_tx_stream = xStreamBufferCreate(std::max(tx_buf_size * 4, 4 * 1024), 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = + xStreamBufferCreate(std::max(tx_buf_size * 4, 4 * 1024), tx_buf_size); if (audio_tx_stream == nullptr) { return fail_audio_init("Failed to allocate the audio TX stream buffer"); } @@ -418,24 +421,31 @@ float Esp32P4ModuleDevKit::microphone_volume() const { return mic_volume_; } bool Esp32P4ModuleDevKit::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - (void)cv; // unused: this task paces itself on the finite-timeout I2S write below - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); + (void)cv; // unused: this task paces itself on the finite-timeout receive/write below size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); + uint8_t *tx_buf = audio_tx_buffer.data(); + // Wait (bounded) for a whole block rather than taking whatever is queued and + // zero-padding it to a block: producers push audio in bursts (one video frame + // at a time, with frame-to-frame jitter), and the stream buffer exists to + // absorb exactly that. Taking a partial block padded it with silence every + // time the stream momentarily held less than one block, which is audible as + // stutter whenever the producer's cadence is uneven. The DMA is still fed + // whole blocks at a constant cadence while audio is flowing (the stream's + // trigger level is one block); only after the timeout -- producer idle or + // slower than real time -- is a partial block written as is (the I2S driver + // concatenates partial writes without gaps), or silence when there is none. + // The wait happens before taking audio_tx_mutex_ so a sample-rate change is + // never held off by it. + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); // only ever hand whole 16-bit samples to I2S; a partial sample would shift // the framing of everything after it - available &= ~static_cast(1); - uint8_t *tx_buf = audio_tx_buffer.data(); - memset(tx_buf, 0, buffer_size); - if (available > 0) { - xStreamBufferReceive(audio_tx_stream, tx_buf, available, 0); - } - // Always write a full, frame-aligned buffer (queued samples zero-padded to - // buffer_size) so the I2S DMA is fed at a constant cadence - matching the - // esp-box / t-deck / m5stack-tab5 playback path. Writing only `available` - // bytes makes the drain cadence variable and interleaves whole frames of - // silence into bursty streams, which sounds choppy/glitchy. - // + got &= ~static_cast(1); + size_t to_write = buffer_size; + if (got == 0) { + memset(tx_buf, 0, buffer_size); + } else { + to_write = got; + } // Use a finite write timeout (not portMAX_DELAY) so this task returns // periodically and can observe a stop request; an infinite write would block // Task::stop() from joining during teardown if the I2S sink ever stalls. @@ -445,15 +455,14 @@ bool Esp32P4ModuleDevKit::audio_task_callback(std::mutex &m, std::condition_vari // Serialize with set_audio_sample_rate(), which may disable/reconfigure the // channel; writing to a disabled channel would race the reconfig. std::lock_guard lk(audio_tx_mutex_); - err = - i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, &bytes_written, pdMS_TO_TICKS(100)); + err = i2s_channel_write(audio_tx_handle, tx_buf, to_write, &bytes_written, pdMS_TO_TICKS(100)); } - if (err != ESP_OK || bytes_written != buffer_size) { + if (err != ESP_OK || bytes_written != to_write) { // Rate-limit: a stalled I2S sink would otherwise emit this every ~100 ms. static uint32_t write_warn_count = 0; if ((write_warn_count++ % 100) == 0) { logger_.warn("i2s_channel_write: {} ({} of {} bytes written, occurrence {})", - esp_err_to_name(err), bytes_written, buffer_size, write_warn_count); + esp_err_to_name(err), bytes_written, to_write, write_warn_count); } } // honor a stop request per the Task contract: check/clear notified under m From 2d919da95571bf119131a9da54e26a01b5bd403f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:37 -0500 Subject: [PATCH 05/20] fix(esp32-p4-nano): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/esp32-p4-nano/src/audio.cpp | 39 +++++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/components/esp32-p4-nano/src/audio.cpp b/components/esp32-p4-nano/src/audio.cpp index 4c9d0da15c..a9aa9406df 100644 --- a/components/esp32-p4-nano/src/audio.cpp +++ b/components/esp32-p4-nano/src/audio.cpp @@ -114,7 +114,10 @@ bool Esp32P4Nano::initialize_audio(uint32_t sample_rate, // whole UI sound clip fits and play_audio() can enqueue it without blocking. auto tx_buf_size = calc_audio_buffer_size(sample_rate); audio_tx_buffer.resize(tx_buf_size); - audio_tx_stream = xStreamBufferCreate(std::max(tx_buf_size * 4, 64 * 1024), 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = + xStreamBufferCreate(std::max(tx_buf_size * 4, 64 * 1024), tx_buf_size); if (audio_tx_stream == nullptr) { return fail_audio_init("Failed to allocate the audio TX stream buffer"); } @@ -297,27 +300,29 @@ float Esp32P4Nano::microphone_volume() const { return mic_volume_; } bool Esp32P4Nano::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); + uint8_t *tx_buf = audio_tx_buffer.data(); + // Wait (bounded) for a whole block rather than taking whatever is queued and + // zero-padding it to a block: producers push audio in bursts (one video frame + // at a time, with frame-to-frame jitter), and the stream buffer exists to + // absorb exactly that. Taking a partial block padded it with silence every + // time the stream momentarily held less than one block, which is audible as + // stutter whenever the producer's cadence is uneven. The DMA is still fed + // whole blocks at a constant cadence while audio is flowing (the stream's + // trigger level is one block); only after the timeout -- producer idle or + // slower than real time -- is a partial block written as is (the I2S driver + // concatenates partial writes without gaps), or silence when there is none. + // Finite timeouts (not portMAX_DELAY) so this task returns periodically and + // can observe a stop request during teardown. + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); // only ever hand whole 16-bit samples to I2S; a partial sample would shift // the framing of everything after it - available &= ~static_cast(1); - uint8_t *tx_buf = audio_tx_buffer.data(); - memset(tx_buf, 0, buffer_size); - // Use a finite write timeout (not portMAX_DELAY) so this task returns - // periodically and can observe a stop request; an infinite write would block - // Task::stop() from joining during teardown if the I2S sink ever stalls. - if (available == 0) { + got &= ~static_cast(1); + if (got == 0) { + memset(tx_buf, 0, buffer_size); i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, pdMS_TO_TICKS(100)); } else { - xStreamBufferReceive(audio_tx_stream, tx_buf, available, 0); - // Always write a full, frame-aligned buffer (queued samples zero-padded to - // buffer_size) so the I2S DMA is fed at a constant cadence - matching the - // esp-box / t-deck / m5stack-tab5 playback path. Writing only `available` - // bytes makes the drain cadence variable and interleaves whole frames of - // silence into bursty streams, which sounds choppy/glitchy. - i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, pdMS_TO_TICKS(100)); + i2s_channel_write(audio_tx_handle, tx_buf, got, NULL, pdMS_TO_TICKS(100)); } // honor a stop request per the Task contract: check/clear notified under m std::unique_lock lock(m); From f4ce5a8e174f6c872179a3d7df96bccb8648b9e5 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:38 -0500 Subject: [PATCH 06/20] fix(esp32-p4-wifi6-dev-kit): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../esp32-p4-wifi6-dev-kit/src/audio.cpp | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/components/esp32-p4-wifi6-dev-kit/src/audio.cpp b/components/esp32-p4-wifi6-dev-kit/src/audio.cpp index 1d410ae28e..94912de8ab 100644 --- a/components/esp32-p4-wifi6-dev-kit/src/audio.cpp +++ b/components/esp32-p4-wifi6-dev-kit/src/audio.cpp @@ -170,7 +170,10 @@ bool Esp32P4Wifi6DevKit::initialize_audio(uint32_t sample_rate, // chunks (see the example's playback loop). auto tx_buf_size = calc_audio_buffer_size(sample_rate, TX_NUM_CHANNELS); audio_tx_buffer.resize(tx_buf_size); - audio_tx_stream = xStreamBufferCreate(std::max(tx_buf_size * 4, 4 * 1024), 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = + xStreamBufferCreate(std::max(tx_buf_size * 4, 4 * 1024), tx_buf_size); if (audio_tx_stream == nullptr) { return fail_audio_init("Failed to allocate the audio TX stream buffer"); } @@ -382,24 +385,31 @@ float Esp32P4Wifi6DevKit::microphone_volume() const { return mic_volume_; } bool Esp32P4Wifi6DevKit::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - (void)cv; // unused: this task paces itself on the finite-timeout I2S write below - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); + (void)cv; // unused: this task paces itself on the finite-timeout receive/write below size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); + uint8_t *tx_buf = audio_tx_buffer.data(); + // Wait (bounded) for a whole block rather than taking whatever is queued and + // zero-padding it to a block: producers push audio in bursts (one video frame + // at a time, with frame-to-frame jitter), and the stream buffer exists to + // absorb exactly that. Taking a partial block padded it with silence every + // time the stream momentarily held less than one block, which is audible as + // stutter whenever the producer's cadence is uneven. The DMA is still fed + // whole blocks at a constant cadence while audio is flowing (the stream's + // trigger level is one block); only after the timeout -- producer idle or + // slower than real time -- is a partial block written as is (the I2S driver + // concatenates partial writes without gaps), or silence when there is none. + // The wait happens before taking audio_tx_mutex_ so a sample-rate change is + // never held off by it. + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); // only ever hand whole 16-bit samples to I2S; a partial sample would shift // the framing of everything after it - available &= ~static_cast(1); - uint8_t *tx_buf = audio_tx_buffer.data(); - memset(tx_buf, 0, buffer_size); - if (available > 0) { - xStreamBufferReceive(audio_tx_stream, tx_buf, available, 0); - } - // Always write a full, frame-aligned buffer (queued samples zero-padded to - // buffer_size) so the I2S DMA is fed at a constant cadence - matching the - // esp-box / t-deck / m5stack-tab5 playback path. Writing only `available` - // bytes makes the drain cadence variable and interleaves whole frames of - // silence into bursty streams, which sounds choppy/glitchy. - // + got &= ~static_cast(1); + size_t to_write = buffer_size; + if (got == 0) { + memset(tx_buf, 0, buffer_size); + } else { + to_write = got; + } // Use a finite write timeout (not portMAX_DELAY) so this task returns // periodically and can observe a stop request; an infinite write would block // Task::stop() from joining during teardown if the I2S sink ever stalls. @@ -409,15 +419,14 @@ bool Esp32P4Wifi6DevKit::audio_task_callback(std::mutex &m, std::condition_varia // Serialize with audio_sample_rate(), which may disable/reconfigure the // channel; writing to a disabled channel would race the reconfig. std::lock_guard lk(audio_tx_mutex_); - err = - i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, &bytes_written, pdMS_TO_TICKS(100)); + err = i2s_channel_write(audio_tx_handle, tx_buf, to_write, &bytes_written, pdMS_TO_TICKS(100)); } - if (err != ESP_OK || bytes_written != buffer_size) { + if (err != ESP_OK || bytes_written != to_write) { // Rate-limit: a stalled I2S sink would otherwise emit this every ~100 ms. static uint32_t write_warn_count = 0; if ((write_warn_count++ % 100) == 0) { logger_.warn("i2s_channel_write: {} ({} of {} bytes written, occurrence {})", - esp_err_to_name(err), bytes_written, buffer_size, write_warn_count); + esp_err_to_name(err), bytes_written, to_write, write_warn_count); } } // honor a stop request per the Task contract: check/clear notified under m From 3c4913328796867057f29781c1ee0f9e80678384 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:40 -0500 Subject: [PATCH 07/20] fix(m5stack-cardputer): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/m5stack-cardputer/src/audio.cpp | 46 ++++++++++++++-------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/components/m5stack-cardputer/src/audio.cpp b/components/m5stack-cardputer/src/audio.cpp index d664c7452d..45cf656d30 100644 --- a/components/m5stack-cardputer/src/audio.cpp +++ b/components/m5stack-cardputer/src/audio.cpp @@ -120,7 +120,9 @@ bool M5StackCardputer::initialize_sound(uint32_t default_audio_rate, // size the transmit buffering from the actual (possibly shared) rate size_t buffer_size = calc_audio_buffer_size(audio_sample_rate()); audio_tx_buffer.resize(buffer_size); - audio_tx_stream = xStreamBufferCreate(buffer_size * 4, 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = xStreamBufferCreate(buffer_size * 4, buffer_size); if (audio_tx_stream == nullptr) { logger_.error("Could not allocate the audio stream buffer"); audio_tx_buffer.clear(); @@ -179,25 +181,35 @@ bool M5StackCardputer::initialize_sound(uint32_t default_audio_rate, bool M5StackCardputer::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - // Queue the next I2S out frame to write - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); + // Queue the next I2S out block. Wait (bounded) for a whole block rather than + // taking whatever is queued and zero-padding it to a block: producers push + // audio in bursts (one video frame at a time, with frame-to-frame jitter), + // and the stream buffer exists to absorb exactly that. Taking a partial block + // padded it with silence every time the stream momentarily held less than + // one block, which is audible as stutter whenever the producer's cadence is + // uneven. The DMA is still fed whole blocks while audio is flowing (the + // stream's trigger level is one block); only after the timeout -- producer + // idle or slower than real time -- is a partial block written as is (the I2S + // driver concatenates partial writes without gaps), or silence when there is + // none. size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); uint8_t *buffer = &audio_tx_buffer[0]; - memset(buffer, 0, buffer_size); - - if (available > 0) { - xStreamBufferReceive(audio_tx_stream, buffer, available, 0); - // The amplifier has no volume control, so scale the samples in software - // according to the current volume / mute state - float scale = mute_ ? 0.0f : (volume_ / 100.0f); - auto *samples = reinterpret_cast(buffer); - size_t num_samples = available / sizeof(int16_t); - for (size_t i = 0; i < num_samples; i++) { - samples[i] = static_cast(samples[i] * scale); - } + size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, pdMS_TO_TICKS(40)); + got &= ~static_cast(1); // whole 16-bit samples only + if (got == 0) { + memset(buffer, 0, buffer_size); + i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); + return false; // don't stop the task + } + // The amplifier has no volume control, so scale the samples in software + // according to the current volume / mute state + float scale = mute_ ? 0.0f : (volume_ / 100.0f); + auto *samples = reinterpret_cast(buffer); + size_t num_samples = got / sizeof(int16_t); + for (size_t i = 0; i < num_samples; i++) { + samples[i] = static_cast(samples[i] * scale); } - i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); + i2s_channel_write(audio_tx_handle, buffer, got, NULL, portMAX_DELAY); return false; // don't stop the task } From 45ba39e4c01d09ff3c0d6d02e3d30eb3b465c56e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:42 -0500 Subject: [PATCH 08/20] fix(m5stack-tab5): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/m5stack-tab5/src/audio.cpp | 30 +++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/components/m5stack-tab5/src/audio.cpp b/components/m5stack-tab5/src/audio.cpp index 5013885962..528320b514 100644 --- a/components/m5stack-tab5/src/audio.cpp +++ b/components/m5stack-tab5/src/audio.cpp @@ -87,7 +87,9 @@ bool M5StackTab5::initialize_audio(uint32_t sample_rate, // Stream buffers and task auto tx_buf_size = calc_audio_buffer_size(sample_rate); audio_tx_buffer.resize(tx_buf_size); - audio_tx_stream = xStreamBufferCreate(tx_buf_size * 4, 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = xStreamBufferCreate(tx_buf_size * 4, tx_buf_size); xStreamBufferReset(audio_tx_stream); // RX buffer for recording audio_rx_buffer.resize(tx_buf_size); @@ -334,20 +336,26 @@ void M5StackTab5::stop_audio_recording() { bool M5StackTab5::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - // Playback: write next buffer worth of audio from stream buffer - // Queue the next I2S out frame to write (matches the esp-box / t-deck path): - // always write a full, frame-aligned buffer with the queued samples zero- - // padded up to buffer_size so the I2S DMA is fed at a constant cadence. - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); + // Playback: write the next block of audio from the stream buffer. Wait + // (bounded) for a whole block rather than taking whatever is queued and + // zero-padding it to a block: producers push audio in bursts (one video frame + // at a time, with frame-to-frame jitter), and the stream buffer exists to + // absorb exactly that. Taking a partial block padded it with silence every + // time the stream momentarily held less than one block, which is audible as + // stutter whenever the producer's cadence is uneven. The DMA is still fed + // whole blocks at a constant cadence while audio is flowing (the stream's + // trigger level is one block); only after the timeout -- producer idle or + // slower than real time -- is a partial block written as is (the I2S driver + // concatenates partial writes without gaps), or silence when there is none. size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); uint8_t *tx_buf = audio_tx_buffer.data(); - memset(tx_buf, 0, buffer_size); - if (available == 0) { + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); + got -= got % 4; // whole 16-bit stereo frames only + if (got == 0) { + memset(tx_buf, 0, buffer_size); i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, portMAX_DELAY); } else { - xStreamBufferReceive(audio_tx_stream, tx_buf, available, 0); - i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, portMAX_DELAY); + i2s_channel_write(audio_tx_handle, tx_buf, got, NULL, portMAX_DELAY); } return false; From 407a20dc74495bf6901b31073fdfd310ad01aada Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:43 -0500 Subject: [PATCH 09/20] fix(smartpanlee-sc01-plus): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../src/smartpanlee-sc01-plus.cpp | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/components/smartpanlee-sc01-plus/src/smartpanlee-sc01-plus.cpp b/components/smartpanlee-sc01-plus/src/smartpanlee-sc01-plus.cpp index 89d559e786..805d9a39df 100644 --- a/components/smartpanlee-sc01-plus/src/smartpanlee-sc01-plus.cpp +++ b/components/smartpanlee-sc01-plus/src/smartpanlee-sc01-plus.cpp @@ -164,7 +164,9 @@ bool SmartPanleeSc01Plus::initialize_i2s(uint32_t default_audio_rate) { auto buffer_size = calc_audio_buffer_size(default_audio_rate); audio_tx_buffer_.resize(buffer_size); - audio_tx_stream_ = xStreamBufferCreate(buffer_size * 4, 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream_ = xStreamBufferCreate(buffer_size * 4, buffer_size); if (!audio_tx_stream_) { logger_.error("Failed to create audio stream buffer"); return false; @@ -524,16 +526,25 @@ bool SmartPanleeSc01Plus::audio_task_callback(std::mutex &m, std::condition_vari (void)cv; (void)task_notified; - auto available = xStreamBufferBytesAvailable(audio_tx_stream_); + // Wait (bounded) for a whole block rather than taking whatever is queued and + // zero-padding it to a block: producers push audio in bursts (one video frame + // at a time, with frame-to-frame jitter), and the stream buffer exists to + // absorb exactly that. Taking a partial block padded it with silence every + // time the stream momentarily held less than one block, which is audible as + // stutter whenever the producer's cadence is uneven. The DMA is still fed + // whole blocks at a constant cadence while audio is flowing (the stream's + // trigger level is one block); only after the timeout -- producer idle or + // slower than real time -- is a partial block written as is (the I2S driver + // concatenates partial writes without gaps), or silence when there is none. auto buffer_size = audio_tx_buffer_.size(); - available = std::min(available, buffer_size); - uint8_t *buffer = audio_tx_buffer_.data(); - std::memset(buffer, 0, buffer_size); - - size_t bytes_received = 0; - if (available > 0) { - bytes_received = xStreamBufferReceive(audio_tx_stream_, buffer, available, 0); + size_t bytes_received = + xStreamBufferReceive(audio_tx_stream_, buffer, buffer_size, pdMS_TO_TICKS(40)); + bytes_received &= ~static_cast(1); // whole 16-bit samples only + if (bytes_received == 0) { + std::memset(buffer, 0, buffer_size); + i2s_channel_write(audio_tx_handle_, buffer, buffer_size, nullptr, portMAX_DELAY); + return false; } if (mute_) { @@ -551,7 +562,7 @@ bool SmartPanleeSc01Plus::audio_task_callback(std::mutex &m, std::condition_vari } } - i2s_channel_write(audio_tx_handle_, buffer, buffer_size, nullptr, portMAX_DELAY); + i2s_channel_write(audio_tx_handle_, buffer, bytes_received, nullptr, portMAX_DELAY); return false; } From 3e95364d8571e5cf734776af427eafde7746e3c4 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:19:45 -0500 Subject: [PATCH 10/20] fix(t-deck): wait for a whole audio block instead of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/t-deck/src/audio.cpp | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/components/t-deck/src/audio.cpp b/components/t-deck/src/audio.cpp index d2657c107c..750a710288 100644 --- a/components/t-deck/src/audio.cpp +++ b/components/t-deck/src/audio.cpp @@ -112,7 +112,9 @@ bool TDeck::initialize_i2s(uint32_t default_audio_rate) { auto buffer_size = calc_audio_buffer_size(default_audio_rate); audio_tx_buffer.resize(buffer_size); - audio_tx_stream = xStreamBufferCreate(buffer_size * 4, 0); + // Trigger level = one block: the audio task waits for a whole block instead of + // padding a partial one with silence (see audio_task_callback). + audio_tx_stream = xStreamBufferCreate(buffer_size * 4, buffer_size); xStreamBufferReset(audio_tx_stream); @@ -147,34 +149,41 @@ bool TDeck::initialize_sound(uint32_t default_audio_rate, } bool TDeck::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { - // Queue the next I2S out frame to write - size_t available = xStreamBufferBytesAvailable(audio_tx_stream); + // Queue the next I2S out block. Wait (bounded) for a whole block rather than + // taking whatever is queued and zero-padding it to a block: producers push + // audio in bursts (one video frame at a time, with frame-to-frame jitter), + // and the stream buffer exists to absorb exactly that. Taking a partial block + // padded it with silence every time the stream momentarily held less than + // one block, which is audible as stutter whenever the producer's cadence is + // uneven. The DMA is still fed whole blocks while audio is flowing (the + // stream's trigger level is one block); only after the timeout -- producer + // idle or slower than real time -- is a partial block written as is (the I2S + // driver concatenates partial writes without gaps), or silence when there is + // none. size_t buffer_size = audio_tx_buffer.size(); - available = std::min(available, buffer_size); uint8_t *buffer = &audio_tx_buffer[0]; - memset(buffer, 0, buffer_size); + size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, pdMS_TO_TICKS(40)); + got &= ~static_cast(1); // whole 16-bit samples only - if (available == 0) { + if (got == 0) { + memset(buffer, 0, buffer_size); i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); } else { - // get the data from the buffer - xStreamBufferReceive(audio_tx_stream, buffer, available, 0); - // apply volume and mute if (mute_) { - memset(buffer, 0, available); + memset(buffer, 0, got); } else { // NOTE: we are actually using int16_t samples, so we need to adjust the // pointers to the buffer to the correct positions. int16_t *ptr = reinterpret_cast(buffer); - for (size_t i = 0; i < (available / 2); ++i) { + for (size_t i = 0; i < (got / 2); ++i) { int32_t sample = (ptr[i] * volume_) / 100.0f; ptr[i] = static_cast(std::clamp(sample, INT16_MIN, INT16_MAX)); } } // write the buffer to the I2S channel - i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); + i2s_channel_write(audio_tx_handle, buffer, got, NULL, portMAX_DELAY); } return false; // don't stop the task } From a138d6b68478690bbeea372da58b51b58b4eb143 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:28 -0500 Subject: [PATCH 11/20] fix(esp-box): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/esp-box/src/audio.cpp | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/components/esp-box/src/audio.cpp b/components/esp-box/src/audio.cpp index f0a16b0146..8843ac4ce3 100644 --- a/components/esp-box/src/audio.cpp +++ b/components/esp-box/src/audio.cpp @@ -114,6 +114,10 @@ bool EspBox::initialize_i2s(uint32_t default_audio_rate) { ESP_ERROR_CHECK(i2s_channel_init_std_mode(audio_tx_handle, &audio_std_cfg)); auto buffer_size = calc_audio_buffer_size(default_audio_rate); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + buffer_size -= buffer_size % (NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(buffer_size); // Trigger level = one block: the consumer waits for a whole block instead of @@ -159,6 +163,14 @@ bool EspBox::initialize_sound(uint32_t default_audio_rate, void EspBox::enable_sound(bool enable) { gpio_set_level(sound_power_pin, enable); } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate, 30 ms at 26.6 kHz), so partial writes only +// happen when the producer is idle or genuinely behind, never because its next +// frame simply had not arrived yet; short enough that idle->silence latency is +// not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool EspBox::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { // Queue the next I2S out block. Wait (bounded) for a whole block rather than // taking a partial one and padding it with silence: producers such as the @@ -171,8 +183,18 @@ bool EspBox::audio_task_callback(std::mutex &m, std::condition_variable &cv, boo // is (the I2S driver concatenates partial writes without gaps). size_t buffer_size = audio_tx_buffer.size(); uint8_t *buffer = &audio_tx_buffer[0]; - size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, pdMS_TO_TICKS(40)); - got -= got % 4; // whole 16-bit stereo frames only + size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } if (got == 0) { memset(buffer, 0, buffer_size); i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); From 0a05fc3bb11580eccb91a3229eec7796d77a8ed3 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:29 -0500 Subject: [PATCH 12/20] fix(esp32-p4-eth): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/esp32-p4-eth/src/audio.cpp | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/components/esp32-p4-eth/src/audio.cpp b/components/esp32-p4-eth/src/audio.cpp index d7a0ca7f02..4a0097f289 100644 --- a/components/esp32-p4-eth/src/audio.cpp +++ b/components/esp32-p4-eth/src/audio.cpp @@ -112,6 +112,10 @@ bool Esp32P4Eth::initialize_audio(uint32_t sample_rate, const espp::Task::BaseCo // The audio task drains this stream buffer to I2S. Size it generously so a // whole UI sound clip fits and play_audio() can enqueue it without blocking. auto tx_buf_size = calc_audio_buffer_size(sample_rate); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + tx_buf_size -= tx_buf_size % (NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(tx_buf_size); // Trigger level = one block: the audio task waits for a whole block instead of // padding a partial one with silence (see audio_task_callback). @@ -297,6 +301,13 @@ void Esp32P4Eth::microphone_volume(float volume) { float Esp32P4Eth::microphone_volume() const { return mic_volume_; } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool Esp32P4Eth::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { size_t buffer_size = audio_tx_buffer.size(); @@ -313,10 +324,18 @@ bool Esp32P4Eth::audio_task_callback(std::mutex &m, std::condition_variable &cv, // concatenates partial writes without gaps), or silence when there is none. // Finite timeouts (not portMAX_DELAY) so this task returns periodically and // can observe a stop request during teardown. - size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); - // only ever hand whole 16-bit samples to I2S; a partial sample would shift - // the framing of everything after it - got &= ~static_cast(1); + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } if (got == 0) { memset(tx_buf, 0, buffer_size); i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, pdMS_TO_TICKS(100)); From 56192b469820fbfa10e102399a7e0bafe2dc84a5 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:30 -0500 Subject: [PATCH 13/20] fix(esp32-p4-function-ev-board): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../esp32-p4-function-ev-board/src/audio.cpp | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/components/esp32-p4-function-ev-board/src/audio.cpp b/components/esp32-p4-function-ev-board/src/audio.cpp index 49554f4bff..7a6b3d4093 100644 --- a/components/esp32-p4-function-ev-board/src/audio.cpp +++ b/components/esp32-p4-function-ev-board/src/audio.cpp @@ -94,6 +94,10 @@ bool Esp32P4FunctionEvBoard::initialize_audio(uint32_t sample_rate, // The audio task drains this stream buffer to I2S. Size it generously so a // whole UI sound clip fits and play_audio() can enqueue it without blocking. auto tx_buf_size = calc_audio_buffer_size(sample_rate); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + tx_buf_size -= tx_buf_size % (NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(tx_buf_size); // Trigger level = one block: the audio task waits for a whole block instead of // padding a partial one with silence (see audio_task_callback). @@ -262,6 +266,13 @@ void Esp32P4FunctionEvBoard::microphone_volume(float volume) { float Esp32P4FunctionEvBoard::microphone_volume() const { return mic_volume_; } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool Esp32P4FunctionEvBoard::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { size_t buffer_size = audio_tx_buffer.size(); @@ -276,10 +287,18 @@ bool Esp32P4FunctionEvBoard::audio_task_callback(std::mutex &m, std::condition_v // trigger level is one block); only after the timeout -- producer idle or // slower than real time -- is a partial block written as is (the I2S driver // concatenates partial writes without gaps), or silence when there is none. - size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); - // only ever hand whole 16-bit samples to I2S; a partial sample would shift - // the framing of everything after it - got &= ~static_cast(1); + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } if (got == 0) { memset(tx_buf, 0, buffer_size); i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, portMAX_DELAY); From b128586488dd64f8c60fe346a7e2d492b11422a3 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:32 -0500 Subject: [PATCH 14/20] fix(esp32-p4-module-dev-kit): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../esp32-p4-module-dev-kit/src/audio.cpp | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/components/esp32-p4-module-dev-kit/src/audio.cpp b/components/esp32-p4-module-dev-kit/src/audio.cpp index b89b3eb045..33f2da58df 100644 --- a/components/esp32-p4-module-dev-kit/src/audio.cpp +++ b/components/esp32-p4-module-dev-kit/src/audio.cpp @@ -188,6 +188,10 @@ bool Esp32P4ModuleDevKit::initialize_audio(uint32_t sample_rate, // how much it queued, so callers stream clips longer than the buffer in // chunks (see the example's playback loop). auto tx_buf_size = calc_audio_buffer_size(sample_rate, TX_NUM_CHANNELS); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + tx_buf_size -= tx_buf_size % (TX_NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(tx_buf_size); // Trigger level = one block: the audio task waits for a whole block instead of // padding a partial one with silence (see audio_task_callback). @@ -419,6 +423,13 @@ void Esp32P4ModuleDevKit::microphone_volume(float volume) { float Esp32P4ModuleDevKit::microphone_volume() const { return mic_volume_; } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool Esp32P4ModuleDevKit::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { (void)cv; // unused: this task paces itself on the finite-timeout receive/write below @@ -436,10 +447,18 @@ bool Esp32P4ModuleDevKit::audio_task_callback(std::mutex &m, std::condition_vari // concatenates partial writes without gaps), or silence when there is none. // The wait happens before taking audio_tx_mutex_ so a sample-rate change is // never held off by it. - size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); - // only ever hand whole 16-bit samples to I2S; a partial sample would shift - // the framing of everything after it - got &= ~static_cast(1); + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = TX_NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } size_t to_write = buffer_size; if (got == 0) { memset(tx_buf, 0, buffer_size); From 9046b5025ae4b772e6350ad7fd6f25e1fc3a5e2d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:33 -0500 Subject: [PATCH 15/20] fix(esp32-p4-nano): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/esp32-p4-nano/src/audio.cpp | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/components/esp32-p4-nano/src/audio.cpp b/components/esp32-p4-nano/src/audio.cpp index a9aa9406df..11afbdcfb4 100644 --- a/components/esp32-p4-nano/src/audio.cpp +++ b/components/esp32-p4-nano/src/audio.cpp @@ -113,6 +113,10 @@ bool Esp32P4Nano::initialize_audio(uint32_t sample_rate, // The audio task drains this stream buffer to I2S. Size it generously so a // whole UI sound clip fits and play_audio() can enqueue it without blocking. auto tx_buf_size = calc_audio_buffer_size(sample_rate); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + tx_buf_size -= tx_buf_size % (NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(tx_buf_size); // Trigger level = one block: the audio task waits for a whole block instead of // padding a partial one with silence (see audio_task_callback). @@ -298,6 +302,13 @@ void Esp32P4Nano::microphone_volume(float volume) { float Esp32P4Nano::microphone_volume() const { return mic_volume_; } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool Esp32P4Nano::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { size_t buffer_size = audio_tx_buffer.size(); @@ -314,10 +325,18 @@ bool Esp32P4Nano::audio_task_callback(std::mutex &m, std::condition_variable &cv // concatenates partial writes without gaps), or silence when there is none. // Finite timeouts (not portMAX_DELAY) so this task returns periodically and // can observe a stop request during teardown. - size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); - // only ever hand whole 16-bit samples to I2S; a partial sample would shift - // the framing of everything after it - got &= ~static_cast(1); + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } if (got == 0) { memset(tx_buf, 0, buffer_size); i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, pdMS_TO_TICKS(100)); From 2857d214f6ddc804a2ab9dd3ce04341ce954d33f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:35 -0500 Subject: [PATCH 16/20] fix(esp32-p4-wifi6-dev-kit): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../esp32-p4-wifi6-dev-kit/src/audio.cpp | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/components/esp32-p4-wifi6-dev-kit/src/audio.cpp b/components/esp32-p4-wifi6-dev-kit/src/audio.cpp index 94912de8ab..83a1c6c682 100644 --- a/components/esp32-p4-wifi6-dev-kit/src/audio.cpp +++ b/components/esp32-p4-wifi6-dev-kit/src/audio.cpp @@ -169,6 +169,10 @@ bool Esp32P4Wifi6DevKit::initialize_audio(uint32_t sample_rate, // how much it queued, so callers stream clips longer than the buffer in // chunks (see the example's playback loop). auto tx_buf_size = calc_audio_buffer_size(sample_rate, TX_NUM_CHANNELS); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + tx_buf_size -= tx_buf_size % (TX_NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(tx_buf_size); // Trigger level = one block: the audio task waits for a whole block instead of // padding a partial one with silence (see audio_task_callback). @@ -383,6 +387,13 @@ void Esp32P4Wifi6DevKit::microphone_volume(float volume) { float Esp32P4Wifi6DevKit::microphone_volume() const { return mic_volume_; } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool Esp32P4Wifi6DevKit::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { (void)cv; // unused: this task paces itself on the finite-timeout receive/write below @@ -400,10 +411,18 @@ bool Esp32P4Wifi6DevKit::audio_task_callback(std::mutex &m, std::condition_varia // concatenates partial writes without gaps), or silence when there is none. // The wait happens before taking audio_tx_mutex_ so a sample-rate change is // never held off by it. - size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); - // only ever hand whole 16-bit samples to I2S; a partial sample would shift - // the framing of everything after it - got &= ~static_cast(1); + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = TX_NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } size_t to_write = buffer_size; if (got == 0) { memset(tx_buf, 0, buffer_size); From a70a89102097ccc4314726118c31876517ad2c8f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:36 -0500 Subject: [PATCH 17/20] fix(m5stack-cardputer): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/m5stack-cardputer/src/audio.cpp | 25 ++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/components/m5stack-cardputer/src/audio.cpp b/components/m5stack-cardputer/src/audio.cpp index 45cf656d30..acbc4327e1 100644 --- a/components/m5stack-cardputer/src/audio.cpp +++ b/components/m5stack-cardputer/src/audio.cpp @@ -119,6 +119,10 @@ bool M5StackCardputer::initialize_sound(uint32_t default_audio_rate, // size the transmit buffering from the actual (possibly shared) rate size_t buffer_size = calc_audio_buffer_size(audio_sample_rate()); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + buffer_size -= buffer_size % (NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(buffer_size); // Trigger level = one block: the audio task waits for a whole block instead of // padding a partial one with silence (see audio_task_callback). @@ -179,6 +183,13 @@ bool M5StackCardputer::initialize_sound(uint32_t default_audio_rate, return audio_task_->start(); } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool M5StackCardputer::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { // Queue the next I2S out block. Wait (bounded) for a whole block rather than @@ -194,8 +205,18 @@ bool M5StackCardputer::audio_task_callback(std::mutex &m, std::condition_variabl // none. size_t buffer_size = audio_tx_buffer.size(); uint8_t *buffer = &audio_tx_buffer[0]; - size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, pdMS_TO_TICKS(40)); - got &= ~static_cast(1); // whole 16-bit samples only + size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } if (got == 0) { memset(buffer, 0, buffer_size); i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); From 08721eb86bcca969cb4fc34c971db57e980b4528 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:38 -0500 Subject: [PATCH 18/20] fix(m5stack-tab5): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/m5stack-tab5/src/audio.cpp | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/components/m5stack-tab5/src/audio.cpp b/components/m5stack-tab5/src/audio.cpp index 528320b514..ee10957a24 100644 --- a/components/m5stack-tab5/src/audio.cpp +++ b/components/m5stack-tab5/src/audio.cpp @@ -86,6 +86,10 @@ bool M5StackTab5::initialize_audio(uint32_t sample_rate, // Stream buffers and task auto tx_buf_size = calc_audio_buffer_size(sample_rate); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + tx_buf_size -= tx_buf_size % (NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(tx_buf_size); // Trigger level = one block: the audio task waits for a whole block instead of // padding a partial one with silence (see audio_task_callback). @@ -334,6 +338,13 @@ void M5StackTab5::stop_audio_recording() { logger_.info("Audio recording stopped"); } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool M5StackTab5::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { // Playback: write the next block of audio from the stream buffer. Wait @@ -349,8 +360,18 @@ bool M5StackTab5::audio_task_callback(std::mutex &m, std::condition_variable &cv // concatenates partial writes without gaps), or silence when there is none. size_t buffer_size = audio_tx_buffer.size(); uint8_t *tx_buf = audio_tx_buffer.data(); - size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, pdMS_TO_TICKS(40)); - got -= got % 4; // whole 16-bit stereo frames only + size_t got = xStreamBufferReceive(audio_tx_stream, tx_buf, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } if (got == 0) { memset(tx_buf, 0, buffer_size); i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, NULL, portMAX_DELAY); From 7a1ad838b6534312ca1f61856dd65be6072e44a2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:39 -0500 Subject: [PATCH 19/20] fix(smartpanlee-sc01-plus): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../src/smartpanlee-sc01-plus.cpp | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/components/smartpanlee-sc01-plus/src/smartpanlee-sc01-plus.cpp b/components/smartpanlee-sc01-plus/src/smartpanlee-sc01-plus.cpp index 805d9a39df..332b008086 100644 --- a/components/smartpanlee-sc01-plus/src/smartpanlee-sc01-plus.cpp +++ b/components/smartpanlee-sc01-plus/src/smartpanlee-sc01-plus.cpp @@ -163,6 +163,10 @@ bool SmartPanleeSc01Plus::initialize_i2s(uint32_t default_audio_rate) { ESP_ERROR_CHECK(i2s_channel_init_std_mode(audio_tx_handle_, &audio_std_cfg_)); auto buffer_size = calc_audio_buffer_size(default_audio_rate); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + buffer_size -= buffer_size % (audio_num_channels * audio_num_bytes_per_channel); audio_tx_buffer_.resize(buffer_size); // Trigger level = one block: the audio task waits for a whole block instead of // padding a partial one with silence (see audio_task_callback). @@ -520,6 +524,13 @@ bool SmartPanleeSc01Plus::update_touch() { return changed; } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool SmartPanleeSc01Plus::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { (void)m; @@ -538,9 +549,18 @@ bool SmartPanleeSc01Plus::audio_task_callback(std::mutex &m, std::condition_vari // concatenates partial writes without gaps), or silence when there is none. auto buffer_size = audio_tx_buffer_.size(); uint8_t *buffer = audio_tx_buffer_.data(); - size_t bytes_received = - xStreamBufferReceive(audio_tx_stream_, buffer, buffer_size, pdMS_TO_TICKS(40)); - bytes_received &= ~static_cast(1); // whole 16-bit samples only + size_t bytes_received = xStreamBufferReceive(audio_tx_stream_, buffer, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = audio_num_channels * audio_num_bytes_per_channel; + if (bytes_received % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", bytes_received); + } + } if (bytes_received == 0) { std::memset(buffer, 0, buffer_size); i2s_channel_write(audio_tx_handle_, buffer, buffer_size, nullptr, portMAX_DELAY); From 000ca836aafb81ce54b1b48f47ba01483b04f972 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:28:40 -0500 Subject: [PATCH 20/20] fix(t-deck): frame-align the audio block; name the receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/t-deck/src/audio.cpp | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/components/t-deck/src/audio.cpp b/components/t-deck/src/audio.cpp index 750a710288..79b0530f36 100644 --- a/components/t-deck/src/audio.cpp +++ b/components/t-deck/src/audio.cpp @@ -110,6 +110,10 @@ bool TDeck::initialize_i2s(uint32_t default_audio_rate) { ESP_ERROR_CHECK(i2s_channel_init_std_mode(audio_tx_handle, &audio_std_cfg)); auto buffer_size = calc_audio_buffer_size(default_audio_rate); + // the block (and so the stream's trigger level) must be a whole number of + // frames: the sample-rate formula can land between frames, and a block that + // split one would desynchronize the framing of everything received after it + buffer_size -= buffer_size % (NUM_CHANNELS * NUM_BYTES_PER_CHANNEL); audio_tx_buffer.resize(buffer_size); // Trigger level = one block: the audio task waits for a whole block instead of @@ -148,6 +152,13 @@ bool TDeck::initialize_sound(uint32_t default_audio_rate, return audio_task_->start(); } +// How long the audio task waits for a whole block before writing a partial one +// (or silence). Longer than one block at every rate the board supports (a block +// is 1/60 s at the default rate), so partial writes only happen when the +// producer is idle or genuinely behind, never because its next frame simply had +// not arrived yet; short enough that idle->silence latency is not noticeable. +static constexpr uint32_t AUDIO_TX_RECEIVE_TIMEOUT_MS = 40; + bool TDeck::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified) { // Queue the next I2S out block. Wait (bounded) for a whole block rather than // taking whatever is queued and zero-padding it to a block: producers push @@ -162,8 +173,18 @@ bool TDeck::audio_task_callback(std::mutex &m, std::condition_variable &cv, bool // none. size_t buffer_size = audio_tx_buffer.size(); uint8_t *buffer = &audio_tx_buffer[0]; - size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, pdMS_TO_TICKS(40)); - got &= ~static_cast(1); // whole 16-bit samples only + size_t got = xStreamBufferReceive(audio_tx_stream, buffer, buffer_size, + pdMS_TO_TICKS(AUDIO_TX_RECEIVE_TIMEOUT_MS)); + // the block is frame-aligned and producers queue whole frames, so this cannot + // happen; if it ever does, do not silently drop bytes (that would desynchronize + // the framing of everything after them) -- say so + constexpr size_t frame_bytes = NUM_CHANNELS * NUM_BYTES_PER_CHANNEL; + if (got % frame_bytes != 0) { + static uint32_t misaligned_count = 0; + if ((misaligned_count++ % 100) == 0) { + logger_.warn("audio stream holds a partial frame ({} bytes)", got); + } + } if (got == 0) { memset(buffer, 0, buffer_size);